Compare commits

..
0 Commits
118 changed files with 1117 additions and 15966 deletions

No files matched your search

+4 -29
View File
@@ -1,39 +1,14 @@
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 { vrrp_instance VI_1 {
state BACKUP state MASTER
interface eth0 interface eth0
virtual_router_id 51 virtual_router_id 51
priority 101 priority 101 # 101 = Priorità più alta (Master)
advert_int 2 advert_int 1
preempt_delay 30
unicast_src_ip 192.168.128.80
unicast_peer {
192.168.128.81
}
authentication { authentication {
auth_type PASS auth_type PASS
auth_pass @Dedelove1 auth_pass @Dedelove1
} }
virtual_ipaddress { virtual_ipaddress {
192.168.128.85 192.168.128.85 # Il nostro VIP
} }
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"
} }
+2 -27
View File
@@ -1,28 +1,9 @@
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 { vrrp_instance VI_1 {
state BACKUP state BACKUP
interface eth0 interface eth0
virtual_router_id 51 virtual_router_id 51
priority 100 priority 100 # 100 = Priorità più bassa (Backup)
advert_int 2 advert_int 1
preempt_delay 30
unicast_src_ip 192.168.128.81
unicast_peer {
192.168.128.80
}
authentication { authentication {
auth_type PASS auth_type PASS
auth_pass @Dedelove1 auth_pass @Dedelove1
@@ -30,10 +11,4 @@ vrrp_instance VI_1 {
virtual_ipaddress { virtual_ipaddress {
192.168.128.85 192.168.128.85
} }
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"
} }
-9
View File
@@ -1,9 +0,0 @@
# Override opzionali per weekly-maintenance.sh (Pi-1 Master)
# HOST_LABEL="Pi-1 (Master)"
# 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=...
-10
View File
@@ -1,10 +0,0 @@
# Override opzionali per weekly-maintenance.sh (Pi-2 Backup)
# HOST_LABEL="Pi-2 (Backup)"
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
# 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=...
-4
View File
@@ -1,4 +0,0 @@
# Copia su Pi1: /home/daniely/.config/loogle-notify.env
LOOGLE_CASA_URL=https://casa.loogle.it
LOOGLE_NOTIFY_MODE=web
LOOGLE_INTERNAL_TOKEN=
-2
View File
@@ -1,2 +0,0 @@
#!/bin/bash
exec /home/daniely/rete/scripts/dnsexit-update.sh
+48 -48
View File
@@ -1,57 +1,57 @@
#!/bin/bash #!/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
REMOTE_IP="${REMOTE_IP:-192.168.128.81}" # --- CONFIGURAZIONE ---
REMOTE_USER="${REMOTE_USER:-daniely}" REMOTE_IP="192.168.128.81"
DS920_IP="${DS920_IP:-192.168.128.100}" REMOTE_USER="daniely"
DS920_USER="${DS920_USER:-daniely}" # Cartella che contiene 'data' e 'letsencrypt'
DS920_NPM_ROOT="${DS920_NPM_ROOT:-/volume1/extrema/npm}" SOURCE_BASE="/home/daniely/docker/npm"
SOURCE_BASE="${SOURCE_BASE:-/home/daniely/docker/npm}" TEMP_FILE="/tmp/npm_full_clone.tar.gz"
TEMP_FILE="${TEMP_FILE:-/tmp/npm_full_clone.tar.gz}"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] sync-npm: $*"; } echo "[$(date)] Avvio clonazione totale NPM (Master -> Backup)..."
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 tar --exclude='*.log' -czf "$TEMP_FILE" -C "$SOURCE_BASE" data letsencrypt
sudo chown "${USER}:${USER}" "$TEMP_FILE"
# --- Pi-2 (hot backup: stop/extract/start) --- # 2. Assegna l'archivio all'utente corrente (per poterlo spedire via SCP)
log "Invio archivio al Pi-2 ($REMOTE_IP)..." sudo chown $USER:$USER "$TEMP_FILE"
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' # 3. Spedisce l'archivio al Pi-2
set -e echo "Invio archivio al Pi-2 ($REMOTE_IP)..."
echo " ...Stop NPM..." scp -o ConnectTimeout=10 "$TEMP_FILE" "$REMOTE_USER@$REMOTE_IP:/tmp/"
docker stop npm 2>/dev/null || true
echo " ...Estrazione dati..." if [ $? -eq 0 ]; then
sudo rm -rf /home/daniely/docker/npm/data/* /home/daniely/docker/npm/letsencrypt/* echo "Trasferimento riuscito. Applicazione sul Backup..."
sudo tar -xzf /tmp/npm_full_clone.tar.gz -C /home/daniely/docker/npm/
sudo chown -R root:root /home/daniely/docker/npm/ # 4. Comanda al Pi-2 di:
echo " ...Start NPM..." # a) Arrestare NPM (per sbloccare il database)
docker start npm # b) Scompattare sovrascrivendo tutto
rm -f /tmp/npm_full_clone.tar.gz # c) Ripristinare i permessi di root
EOS # d) Riavviare NPM
log "Pi-2 sincronizzato OK" 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."
else else
log "ERRORE: trasferimento Pi-2 fallito" echo "[$(date)] ERRORE CRITICO: Trasferimento fallito."
fi fi
# --- DS920 NPM3 (dormiente) --- # 5. Pulizia locale
log "Stream archivio al DS920 ($DS920_IP)..." rm "$TEMP_FILE"
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
sudo rm -f "$TEMP_FILE"
log "Sincronizzazione NPM completata."
+47 -3
View File
@@ -1,3 +1,47 @@
#!/usr/bin/env bash #!/bin/bash
# Wrapper legacy — redirige al modulo infra-monitor
exec /home/daniely/rete/infra-monitor/pi2-watchdog.sh "$@" # ================================================
# SENTINELLA DI BACKUP (Gira su Pi-1 Master)
# Controlla solo che il Pi-2 sia vivo.
# ================================================
# --- CONFIGURAZIONE ---
# 👇👇 INSERISCI I TUOI DATI VERI QUI 👇👇
BOT_TOKEN="8155587974:AAF9OekvBpixtk8ZH6KoIc0L8edbhdXt7A4"
CHAT_ID="64463169"
# 👆👆 FINE MODIFICHE 👆👆
# Il bersaglio da controllare (Pi-2 Backup)
TARGET_IP="192.168.128.81"
TARGET_NAME="🍓 Pi-2 (Backup & Monitor)"
# File di stato specifico per questo controllo
STATE_FILE="/tmp/pi2_watchdog.state"
# Funzione per inviare messaggi Telegram con timeout breve
send_telegram() {
MSG="$1"
curl -s --max-time 5 -X POST "https://api.telegram.org/bot$BOT_TOKEN/sendMessage" -d chat_id="$CHAT_ID" -d text="$MSG" -d parse_mode="Markdown" > /dev/null 2>&1
}
# Inizializza stato se non esiste
if [ ! -f "$STATE_FILE" ]; then echo "UP" > "$STATE_FILE"; fi
LAST_STATE=$(cat "$STATE_FILE")
# --- IL PING ---
# 3 tentativi, 2 secondi di timeout l'uno.
# Basta che uno risponda per considerare il bersaglio UP.
if ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1; then
# === È ONLINE ===
if [ "$LAST_STATE" == "DOWN" ]; then
send_telegram "✅ **RISOLTO: $TARGET_NAME è tornato ONLINE!**%0AIl monitoraggio di rete è di nuovo attivo."
echo "UP" > "$STATE_FILE"
fi
else
# === È OFFLINE ===
if [ "$LAST_STATE" == "UP" ]; then
# Usiamo un'icona diversa per distinguerlo subito
send_telegram "🛡️ **ALLARME SICUREZZA: $TARGET_NAME è OFFLINE!**%0A%0AAttenzione: Il sistema di monitoraggio principale (Super Watchdog) non sta funzionando."
echo "DOWN" > "$STATE_FILE"
fi
fi
-597
View File
@@ -1,597 +0,0 @@
#!/bin/bash
# ==============================================================================
# Manutenzione settimanale Raspberry Pi
# Aggiorna OS, Pi-hole, EEPROM; verifica Docker/pip; report + Telegram.
# Conclude sempre con reboot host (salvo --no-reboot o REBOOT_ON_SUCCESS=false).
# Eseguire come root (cron root).
# ==============================================================================
set -uo pipefail
# PATH completo: il crontab root non eredita /usr/sbin (serve per shutdown, ecc.)
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
CONF_FILE="/etc/weekly-maintenance.conf"
LOG_FILE="/var/log/weekly-maintenance.log"
REPORT_FILE="/var/log/weekly-maintenance-report.txt"
TOKEN_FILE="/etc/telegram_dpc_bot_token"
CHAT_ID="64463169"
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
TELEGRAM_ENABLED=true
CHECK_PIP3=false
DOCKER_IGNORE_IMAGES=()
DOCKER_PINNED_IMAGES=()
declare -a ERRORS=()
declare -a WARNINGS=()
declare -a NOTES=()
REBOOT_RECOMMENDED=false
APT_CHANGED=false
DOCKER_UPDATED=false
for arg in "$@"; do
case "$arg" in
--no-reboot) REBOOT_ON_SUCCESS=false ;;
esac
done
# --- Configurazione host (override via /etc/weekly-maintenance.conf) ---
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
# 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
if [[ -f "$CONF_FILE" ]]; then
# shellcheck source=/dev/null
source "$CONF_FILE"
fi
# --- Utility ---
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
}
append_report() {
printf '%s\n' "$*" >> "$REPORT_FILE"
}
run_step() {
local title="$1"
shift
local output
local rc=0
log "▶ $title"
append_report ""
append_report "=== $title ==="
output="$("$@" 2>&1)" || rc=$?
if [[ -n "$output" ]]; then
append_report "$output"
log "$output"
fi
if [[ $rc -eq 0 ]]; then
NOTES+=("OK: $title")
log "✓ $title completato"
else
ERRORS+=("$title (exit $rc)")
log "✗ $title fallito (exit $rc)"
fi
return 0
}
load_telegram_token() {
if [[ ! -f "$TOKEN_FILE" ]]; then
WARNINGS+=("Token Telegram assente ($TOKEN_FILE): notifiche disabilitate")
TELEGRAM_ENABLED=false
return 1
fi
BOT_TOKEN=$(tr -d '\n\r' < "$TOKEN_FILE")
if [[ -z "$BOT_TOKEN" ]]; then
WARNINGS+=("Token Telegram vuoto: notifiche disabilitate")
TELEGRAM_ENABLED=false
return 1
fi
return 0
}
send_telegram() {
local msg="$1"
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
curl -s --max-time 15 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
--data-urlencode "text=${msg}" \
-d "parse_mode=Markdown" > /dev/null 2>&1 || true
}
send_telegram_document() {
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
[[ -f "$REPORT_FILE" ]] || return 0
curl -s --max-time 30 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \
-F "chat_id=${CHAT_ID}" \
-F "document=@${REPORT_FILE}" \
-F "caption=Report completo ${HOST_LABEL}" > /dev/null 2>&1 || true
}
require_root() {
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "Eseguire come root (es. cron root)." >&2
exit 1
fi
}
image_is_ignored() {
local image="$1"
local ignored
for ignored in "${DOCKER_IGNORE_IMAGES[@]}"; do
[[ "$image" == "$ignored" ]] && return 0
done
return 1
}
# 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#*|}
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
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 \
"${env_args[@]}" \
"$WATCHTOWER_IMAGE" \
--run-once 2>&1) || rc=$?
if [[ -n "$output" ]]; then
append_report "$output"
printf '%s\n' "$output" | tail -30 | while IFS= read -r line; do log "$line"; done
fi
if [[ $rc -eq 0 ]]; then
NOTES+=("Watchtower run-once OK")
if grep -q "updated=[1-9]" <<< "$output"; then
DOCKER_UPDATED=true
REBOOT_RECOMMENDED=true
NOTES+=("Watchtower: container aggiornati")
fi
else
ERRORS+=("Watchtower run-once (exit $rc)")
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)
if [[ -n "$holds" ]]; then
WARNINGS+=("Pacchetti apt bloccati (hold): ${holds//$'\n'/, }")
append_report "$holds"
fi
local upgradable
upgradable=$(apt list --upgradable 2>/dev/null | tail -n +2 || true)
if [[ -n "$upgradable" ]]; then
WARNINGS+=("Restano pacchetti non aggiornati dopo full-upgrade")
append_report "$upgradable"
fi
}
audit_pihole_versions() {
local out
out=$("$PIHOLE_BIN" -v 2>&1 || true)
append_report "$out"
if grep -qiE 'version is .*\(Latest: .*\)' <<< "$out"; then
while IFS= read -r line; do
local current latest
current=$(sed -n 's/.*Version is \([^ ]*\).*/\1/p' <<< "$line")
latest=$(sed -n 's/.*Latest: \([^)]*\).*/\1/p' <<< "$line")
if [[ -n "$current" && -n "$latest" && "$current" != "$latest" && "$current" != "N/A" ]]; then
WARNINGS+=("Pi-hole non allineato: $line")
fi
done <<< "$out"
fi
}
audit_eeprom() {
command -v rpi-eeprom-update >/dev/null 2>&1 || return 0
local out
out=$(rpi-eeprom-update 2>&1 || true)
append_report "$out"
if grep -qiE 'UPDATE REQUIRED|update available|NEW EEPROM' <<< "$out"; then
log "EEPROM: aggiornamento disponibile, applico..."
append_report ""
append_report "=== rpi-eeprom-update -a ==="
if rpi-eeprom-update -a >> "$REPORT_FILE" 2>&1; then
NOTES+=("EEPROM aggiornato")
REBOOT_RECOMMENDED=true
else
ERRORS+=("rpi-eeprom-update -a")
fi
else
NOTES+=("EEPROM: aggiornato")
fi
}
audit_docker() {
command -v docker >/dev/null 2>&1 || return 0
append_report ""
append_report "=== Docker audit ==="
local exited
exited=$(docker ps -a --filter "status=exited" --format '{{.Names}} ({{.Image}})' 2>/dev/null || true)
if [[ -n "$exited" ]]; then
WARNINGS+=("Container Docker fermati (Exited): ${exited//$'\n'/, }")
append_report "Exited:"
append_report "$exited"
fi
local name image
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" && continue
if [[ "$image" =~ ^[0-9a-f]{12}$ ]]; then
WARNINGS+=("Container '$name' usa immagine per ID ($image): preferire un tag versionato")
fi
local pinned
for pinned in "${DOCKER_PINNED_IMAGES[@]}"; do
if [[ "$image" == "$pinned" ]]; then
WARNINGS+=("Container '$name' usa tag bloccato ($pinned): Watchtower non passerà a :latest")
fi
done
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
}
audit_pip3() {
[[ "$CHECK_PIP3" == true ]] || return 0
command -v pip3 >/dev/null 2>&1 || return 0
append_report ""
append_report "=== pip3 outdated (sistema) ==="
local outdated count
outdated=$(pip3 list --outdated --format=columns 2>/dev/null | tail -n +3 || true)
if [[ -n "$outdated" ]]; then
count=$(wc -l <<< "$outdated")
append_report "$outdated"
WARNINGS+=("pip3: $count pacchetti Python di sistema non aggiornati (aggiornamento manuale/consigliato in venv)")
else
NOTES+=("pip3: tutti aggiornati")
fi
}
build_telegram_summary() {
local icon status
if ((${#ERRORS[@]} > 0)); then
icon="🚨"
status="ERRORI"
elif ((${#WARNINGS[@]} > 0)); then
icon="⚠️"
status="WARNINGS"
else
icon="✅"
status="OK"
fi
local msg="${icon} *Manutenzione settimanale ${HOST_LABEL}*
Stato: *${status}*
Data: $(date '+%d/%m/%Y %H:%M')"
if [[ "$APT_CHANGED" == true ]]; then
msg+="
📦 apt: pacchetti aggiornati"
fi
if [[ "$DOCKER_UPDATED" == true ]]; then
msg+="
🐳 Docker: container aggiornati"
fi
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
msg+="
🔄 Reboot host programmato tra ${REBOOT_DELAY_MIN} min (fine manutenzione)"
fi
if ((${#ERRORS[@]} > 0)); then
msg+="
*Errori (${#ERRORS[@]}):*"
local e
for e in "${ERRORS[@]}"; do
msg+="
• ${e}"
done
fi
if ((${#WARNINGS[@]} > 0)); then
msg+="
*Avvisi (${#WARNINGS[@]}):*"
local w
local n=0
for w in "${WARNINGS[@]}"; do
((n++)) || true
[[ $n -le 8 ]] && msg+="
• ${w}"
done
if ((${#WARNINGS[@]} > 8)); then
msg+="
• … +$((${#WARNINGS[@]} - 8)) avvisi (vedi report)"
fi
fi
if ((${#ERRORS[@]} == 0 && ${#WARNINGS[@]} == 0)); then
msg+="
Tutto aggiornato. Log: \`${LOG_FILE}\`"
else
msg+="
Report completo allegato o in \`${REPORT_FILE}\`"
fi
send_telegram "$msg"
if ((${#ERRORS[@]} > 0 || ${#WARNINGS[@]} > 0)); then
send_telegram_document
fi
}
# --- Main ---
require_root
: > "$REPORT_FILE"
log "========== Inizio manutenzione settimanale ($HOST_LABEL) =========="
append_report "Manutenzione settimanale - $HOST_LABEL"
append_report "Avviata: $(date '+%Y-%m-%d %H:%M:%S')"
append_report "Host: $(hostname -f) ($(hostname -I | awk '{print $1}'))"
load_telegram_token || true
# 1. Aggiornamento pacchetti sistema
if apt update >> "$REPORT_FILE" 2>&1; then
NOTES+=("apt update OK")
log "✓ apt update"
else
ERRORS+=("apt update")
log "✗ apt update fallito"
fi
BEFORE_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
if apt full-upgrade -y >> "$REPORT_FILE" 2>&1; then
NOTES+=("apt full-upgrade OK")
log "✓ apt full-upgrade"
AFTER_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
if [[ "$BEFORE_UPGRADES" -gt "$AFTER_UPGRADES" ]] || [[ "$BEFORE_UPGRADES" -gt 0 ]]; then
APT_CHANGED=true
REBOOT_RECOMMENDED=true
fi
else
ERRORS+=("apt full-upgrade")
log "✗ apt full-upgrade fallito"
fi
run_step "apt autoremove" apt autoremove -y
audit_apt
# 2. Pi-hole
if [[ -x "$PIHOLE_BIN" ]]; then
run_step "pihole -up" "$PIHOLE_BIN" -up
audit_pihole_versions
else
ERRORS+=("pihole non trovato in $PIHOLE_BIN")
fi
# 3. EEPROM firmware
audit_eeprom
# 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
audit_pip3
append_report ""
append_report "=== Riepilogo ==="
append_report "Errori: ${#ERRORS[@]}"
append_report "Avvisi: ${#WARNINGS[@]}"
if ((${#ERRORS[@]} > 0)); then
append_report "Dettaglio errori:"
printf '%s\n' "${ERRORS[@]}" >> "$REPORT_FILE"
fi
if ((${#WARNINGS[@]} > 0)); then
append_report "Dettaglio avvisi:"
printf '%s\n' "${WARNINGS[@]}" >> "$REPORT_FILE"
fi
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
# 5b. Il peer deve poter coprire: niente reboot se è giù o in manutenzione
# (31/10: Pi-1 riavviato alle 04:50 con Pi-2 già giù → rete senza nessun nodo).
REBOOT_GUARD="/home/daniely/rete/scripts/maintenance-reboot-guard.sh"
if [[ "$REBOOT_ON_SUCCESS" == true && -x "$REBOOT_GUARD" ]]; then
if guard_msg="$("$REBOOT_GUARD" check 2>&1)"; then
log "Guardia reboot: ${guard_msg}"
else
REBOOT_ON_SUCCESS=false
WARNINGS+=("Reboot saltato: ${guard_msg}")
log "⚠ Reboot saltato: ${guard_msg}"
fi
fi
build_telegram_summary
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
log "Reboot host programmato tra ${REBOOT_DELAY_MIN} minuti (fine manutenzione settimanale)"
append_report ""
append_report "=== Reboot ==="
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
# Annuncio al peer: prende il VIP ma non pivota le app, e se il nodo non
# torna entro il TTL il suo watchdog fa il failover vero.
[[ -x "$REBOOT_GUARD" ]] && "$REBOOT_GUARD" announce "$REBOOT_DELAY_MIN" || true
/usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
ERRORS+=("shutdown -r (reboot programmato)")
log "✗ Impossibile programmare il reboot"
}
else
log "Reboot disabilitato (--no-reboot o REBOOT_ON_SUCCESS=false)"
append_report ""
append_report "=== Reboot ==="
append_report "Saltato (disabilitato)"
fi
log "========== Fine manutenzione settimanale =========="
exit $(( ${#ERRORS[@]} > 0 ? 1 : 0 ))
+16 -37
View File
@@ -1,47 +1,36 @@
#!/bin/bash #!/bin/bash
# ============================================================================== # ==============================================================================
# AUTO GIT BACKUP - DINAMICO # 💾 AUTO GIT BACKUP - DINAMICO
# Sincronizza automaticamente tutti gli script .sh e .py dal Pi2 e dal Pi1 # Sincronizza automaticamente tutti gli script .sh e .py dal Pi2 e dal Pi1
# verso il repository Gitea locale, poi esegue il push. # verso il repository Gitea locale, poi esegue il push.
# ============================================================================== # ==============================================================================
set -u
# --- CONFIGURAZIONE --- # --- CONFIGURAZIONE ---
REPO_DIR="/home/daniely/loogle-repo" REPO_DIR="/home/daniely/loogle-repo"
LOG_FILE="/var/log/git-backup.log" LOG_FILE="/var/log/git-backup.log"
DATE=$(date +"%Y-%m-%d %H:%M") 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 # IP del Pi1 (Master) da cui prelevare i file remoti
PI1_IP="192.168.128.80" PI1_IP="192.168.128.80"
PI1_USER="daniely" PI1_USER="daniely"
# Redirige tutto l'output (stdout e stderr) nel log # Redirige tutto l'output (stdout e stderr) nel log
exec >> "$LOG_FILE" 2>&1 exec >> $LOG_FILE 2>&1
echo "=== Inizio Backup Git: $DATE ===" echo "=== Inizio Backup Git: $DATE ==="
fail() {
echo "❌ $1"
echo "=== Fine Backup Git (ERRORE) ==="
exit 1
}
# 1. PREPARAZIONE REPOSITORY # 1. PREPARAZIONE REPOSITORY
# -------------------------- # --------------------------
if [ -d "$REPO_DIR" ]; then if [ -d "$REPO_DIR" ]; then
cd "$REPO_DIR" || fail "Impossibile entrare in $REPO_DIR" cd "$REPO_DIR" || { echo "❌ Errore: Impossibile entrare in $REPO_DIR"; exit 1; }
else else
fail "La cartella $REPO_DIR non esiste." echo "❌ Errore: La cartella $REPO_DIR non esiste."; exit 1;
fi fi
# Aggiorna il repository locale (pull) per evitare conflitti # Aggiorna il repository locale (pull) per evitare conflitti
echo "🔄 Eseguo Git Pull..." echo "🔄 Eseguo Git Pull..."
if ! git pull --ff-only origin main; then git pull origin main
echo "⚠️ Git pull fallito (proseguo con commit locale se possibile)."
fi
# Assicuriamoci che le cartelle di destinazione esistano # Assicuriamoci che le cartelle di destinazione esistano
mkdir -p ./scripts/pi2-backup mkdir -p ./scripts/pi2-backup
@@ -54,6 +43,7 @@ mkdir -p ./configs
echo "📂 Raccolta dinamica file locali (Pi-2)..." echo "📂 Raccolta dinamica file locali (Pi-2)..."
# A. Script nella Home (solo .sh) -> scripts/pi2-backup # 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/" rsync -av --include='*.sh' --exclude='*' /home/daniely/ "$REPO_DIR/scripts/pi2-backup/"
# B. Script del Bot (tutti .py e .sh) -> services/telegram-bot # B. Script del Bot (tutti .py e .sh) -> services/telegram-bot
@@ -71,43 +61,32 @@ fi
echo "📡 Raccolta dinamica file remoti (Pi-1)..." echo "📡 Raccolta dinamica file remoti (Pi-1)..."
# A. Script nella Home remota (.sh e .py) -> scripts/pi1-master # A. Script nella Home remota (.sh e .py) -> scripts/pi1-master
rsync -av -e "ssh -q" --include='*.sh' --include='*.py' --exclude='*' \ # Nota: Richiede che le chiavi SSH siano configurate per non chiedere password
"$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) # B. Recupero file specifici fuori dalla home (Legacy da vecchio script)
scp -q "$PI1_USER@$PI1_IP:/usr/local/bin/dhcp-alert.sh" ./scripts/pi1-master/ 2>/dev/null \ # Se dhcp-alert.sh esiste ancora in /usr/local/bin, lo prendiamo
|| 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 # C. Configurazione Keepalived Remota
if scp -q "$PI1_USER@$PI1_IP:/etc/keepalived/keepalived.conf" ./configs/keepalived_pi1.conf 2>/dev/null; then scp -q $PI1_USER@$PI1_IP:/etc/keepalived/keepalived.conf ./configs/keepalived_pi1.conf 2>/dev/null
if [ $? -eq 0 ]; then
echo "✅ Configurazione Keepalived Pi1 scaricata." echo "✅ Configurazione Keepalived Pi1 scaricata."
else else
echo "⚠️ Impossibile scaricare Keepalived conf da Pi1." echo "⚠️ Impossibile scaricare Keepalived conf da Pi1."
fi fi
# D. Sorgente Loogle MCP (app completa: qui vive solo su Pi1, fuori da git)
# .env e ogni sua copia (.env.bak*, .env.old...) restano esclusi: contengono JWT secret,
# token Paperless/Gitea/HA e password. Passa solo .env.example (l'include va prima).
mkdir -p "$REPO_DIR/services/loogle-mcp"
rsync -av --delete -e "ssh -q" \
--include='.env.example' --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 # 4. GIT PUSH
# ----------- # -----------
if [[ -n $(git status --porcelain) ]]; then # Verifica se ci sono cambiamenti reali
if [[ `git status --porcelain` ]]; then
echo "📝 Rilevati cambiamenti. Eseguo Commit e Push..." echo "📝 Rilevati cambiamenti. Eseguo Commit e Push..."
git add . git add .
git commit -m "Backup automatico script del $DATE" || fail "Commit fallito" git commit -m "Backup automatico script del $DATE"
git push -u origin main
if git push origin main; then
echo "✅ Push completato con successo." echo "✅ Push completato con successo."
else
fail "Push su Gitea fallito (SSH/auth/Gitea down?)."
fi
else else
echo "ℹ️ Nessun cambiamento rilevato. Repository già aggiornato." echo "ℹ️ Nessun cambiamento rilevato. Repository già aggiornato."
fi fi
-2
View File
@@ -1,2 +0,0 @@
#!/bin/bash
exec /home/daniely/rete/scripts/dnsexit-update.sh
+119 -2
View File
@@ -1,2 +1,119 @@
#!/usr/bin/env bash #!/bin/bash
exec /home/daniely/rete/infra-monitor/perimeter-watch.sh "$@"
# ================================================
# 🔍 SUPER WATCHDOG DI RETE (Gira su Pi-2)
# ================================================
# --- CONFIGURAZIONE TELEGRAM ---
# Legge il token dal file sicuro invece che averlo in chiaro
TOKEN_FILE="/etc/telegram_dpc_bot_token"
if [ -f "$TOKEN_FILE" ]; then
# Legge il file e rimuove spazi o 'a capo' accidentali
BOT_TOKEN=$(cat "$TOKEN_FILE" | tr -d '\n' | tr -d '\r')
else
echo "ERRORE CRITICO: Token file non trovato in $TOKEN_FILE"
exit 1
fi
CHAT_ID="64463169"
# 👆👆 FINE MODIFICHE 👆👆
# Cartella dove salvare lo stato di ogni dispositivo
STATE_DIR="/tmp/watchdog_states"
mkdir -p "$STATE_DIR"
# --- LISTA DEI BERSAGLI ---
# Formato: "Nome Amichevole|Indirizzo IP"
# Usa icone diverse per capire subito la gravità.
TARGETS=(
# CRITICI 🚨
"🍓 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"
# INFRASTRUTTURA 🔌
"🗄️ 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 📶
"📶 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"
# CAMERE 📷 (Sottorete .135)
"📷 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"
)
# Funzione per inviare messaggi Telegram
send_telegram() {
MSG="$1"
# Usa un timeout breve per curl per non bloccare lo script se Telegram non va
curl -s --max-time 5 -X POST "https://api.telegram.org/bot$BOT_TOKEN/sendMessage" -d chat_id="$CHAT_ID" -d text="$MSG" -d parse_mode="Markdown" > /dev/null 2>&1
}
# ================================================
# CICLO DI CONTROLLO PRINCIPALE
# ================================================
echo "--- Inizio controllo $(date) ---"
# Loop attraverso ogni dispositivo nella lista
for target_line in "${TARGETS[@]}"; do
# Estrai Nome e IP separati dal simbolo "|"
NAME=$(echo "$target_line" | cut -d'|' -f1)
IP=$(echo "$target_line" | cut -d'|' -f2)
# Crea un nome file sicuro per lo stato usando l'IP (es. 192_168_1_1.state)
SAFE_IP="${IP//./_}"
STATE_FILE="$STATE_DIR/${SAFE_IP}.state"
# Leggi lo stato precedente (default UP se non esiste)
if [ -f "$STATE_FILE" ]; then
LAST_STATE=$(cat "$STATE_FILE")
else
LAST_STATE="UP"
echo "UP" > "$STATE_FILE" # Inizializza
fi
# --- IL PING ---
# Tenta 2 ping, con timeout di 1 secondo l'uno.
# È un controllo rapido. Se fallisce 2 volte di fila, è giù.
ping -c 2 -W 1 "$IP" > /dev/null 2>&1
PING_RESULT=$? # 0 = Successo, altro = Fallimento
if [ $PING_RESULT -eq 0 ]; then
# === ORA È ONLINE ===
if [ "$LAST_STATE" == "DOWN" ]; then
# Era giù, ora è su: NOTIFICA DI RISOLUZIONE
send_telegram "✅ **RISOLTO: $NAME è tornato ONLINE!**%0AIP: \`$IP\`"
echo "UP" > "$STATE_FILE"
echo "--> $NAME tornato UP. Notifica inviata."
fi
else
# === ORA È OFFLINE ===
if [ "$LAST_STATE" == "UP" ]; then
# Era su, ora è giù: NOTIFICA DI ALLARME
# Scegli l'icona in base al tipo di dispositivo per l'allarme
ICON="⚠️"
if [[ "$NAME" == *"🚨"* || "$NAME" == *"🌍"* ]]; then ICON="🚨 CRITICO:"; fi
send_telegram "$ICON **ALLARME: $NAME è OFFLINE!**%0AIP: \`$IP\` non risponde."
echo "DOWN" > "$STATE_FILE"
echo "--> $NAME andato DOWN. Notifica inviata."
else
echo "$NAME ancora DOWN. Nessuna notifica."
fi
fi
done
echo "--- Fine controllo ---"
-71
View File
@@ -1,71 +0,0 @@
#!/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
-597
View File
@@ -1,597 +0,0 @@
#!/bin/bash
# ==============================================================================
# Manutenzione settimanale Raspberry Pi
# Aggiorna OS, Pi-hole, EEPROM; verifica Docker/pip; report + Telegram.
# Conclude sempre con reboot host (salvo --no-reboot o REBOOT_ON_SUCCESS=false).
# Eseguire come root (cron root).
# ==============================================================================
set -uo pipefail
# PATH completo: il crontab root non eredita /usr/sbin (serve per shutdown, ecc.)
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
CONF_FILE="/etc/weekly-maintenance.conf"
LOG_FILE="/var/log/weekly-maintenance.log"
REPORT_FILE="/var/log/weekly-maintenance-report.txt"
TOKEN_FILE="/etc/telegram_dpc_bot_token"
CHAT_ID="64463169"
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
TELEGRAM_ENABLED=true
CHECK_PIP3=false
DOCKER_IGNORE_IMAGES=()
DOCKER_PINNED_IMAGES=()
declare -a ERRORS=()
declare -a WARNINGS=()
declare -a NOTES=()
REBOOT_RECOMMENDED=false
APT_CHANGED=false
DOCKER_UPDATED=false
for arg in "$@"; do
case "$arg" in
--no-reboot) REBOOT_ON_SUCCESS=false ;;
esac
done
# --- Configurazione host (override via /etc/weekly-maintenance.conf) ---
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
# 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
if [[ -f "$CONF_FILE" ]]; then
# shellcheck source=/dev/null
source "$CONF_FILE"
fi
# --- Utility ---
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
}
append_report() {
printf '%s\n' "$*" >> "$REPORT_FILE"
}
run_step() {
local title="$1"
shift
local output
local rc=0
log "▶ $title"
append_report ""
append_report "=== $title ==="
output="$("$@" 2>&1)" || rc=$?
if [[ -n "$output" ]]; then
append_report "$output"
log "$output"
fi
if [[ $rc -eq 0 ]]; then
NOTES+=("OK: $title")
log "✓ $title completato"
else
ERRORS+=("$title (exit $rc)")
log "✗ $title fallito (exit $rc)"
fi
return 0
}
load_telegram_token() {
if [[ ! -f "$TOKEN_FILE" ]]; then
WARNINGS+=("Token Telegram assente ($TOKEN_FILE): notifiche disabilitate")
TELEGRAM_ENABLED=false
return 1
fi
BOT_TOKEN=$(tr -d '\n\r' < "$TOKEN_FILE")
if [[ -z "$BOT_TOKEN" ]]; then
WARNINGS+=("Token Telegram vuoto: notifiche disabilitate")
TELEGRAM_ENABLED=false
return 1
fi
return 0
}
send_telegram() {
local msg="$1"
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
curl -s --max-time 15 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
--data-urlencode "text=${msg}" \
-d "parse_mode=Markdown" > /dev/null 2>&1 || true
}
send_telegram_document() {
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
[[ -f "$REPORT_FILE" ]] || return 0
curl -s --max-time 30 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \
-F "chat_id=${CHAT_ID}" \
-F "document=@${REPORT_FILE}" \
-F "caption=Report completo ${HOST_LABEL}" > /dev/null 2>&1 || true
}
require_root() {
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "Eseguire come root (es. cron root)." >&2
exit 1
fi
}
image_is_ignored() {
local image="$1"
local ignored
for ignored in "${DOCKER_IGNORE_IMAGES[@]}"; do
[[ "$image" == "$ignored" ]] && return 0
done
return 1
}
# 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#*|}
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
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 \
"${env_args[@]}" \
"$WATCHTOWER_IMAGE" \
--run-once 2>&1) || rc=$?
if [[ -n "$output" ]]; then
append_report "$output"
printf '%s\n' "$output" | tail -30 | while IFS= read -r line; do log "$line"; done
fi
if [[ $rc -eq 0 ]]; then
NOTES+=("Watchtower run-once OK")
if grep -q "updated=[1-9]" <<< "$output"; then
DOCKER_UPDATED=true
REBOOT_RECOMMENDED=true
NOTES+=("Watchtower: container aggiornati")
fi
else
ERRORS+=("Watchtower run-once (exit $rc)")
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)
if [[ -n "$holds" ]]; then
WARNINGS+=("Pacchetti apt bloccati (hold): ${holds//$'\n'/, }")
append_report "$holds"
fi
local upgradable
upgradable=$(apt list --upgradable 2>/dev/null | tail -n +2 || true)
if [[ -n "$upgradable" ]]; then
WARNINGS+=("Restano pacchetti non aggiornati dopo full-upgrade")
append_report "$upgradable"
fi
}
audit_pihole_versions() {
local out
out=$("$PIHOLE_BIN" -v 2>&1 || true)
append_report "$out"
if grep -qiE 'version is .*\(Latest: .*\)' <<< "$out"; then
while IFS= read -r line; do
local current latest
current=$(sed -n 's/.*Version is \([^ ]*\).*/\1/p' <<< "$line")
latest=$(sed -n 's/.*Latest: \([^)]*\).*/\1/p' <<< "$line")
if [[ -n "$current" && -n "$latest" && "$current" != "$latest" && "$current" != "N/A" ]]; then
WARNINGS+=("Pi-hole non allineato: $line")
fi
done <<< "$out"
fi
}
audit_eeprom() {
command -v rpi-eeprom-update >/dev/null 2>&1 || return 0
local out
out=$(rpi-eeprom-update 2>&1 || true)
append_report "$out"
if grep -qiE 'UPDATE REQUIRED|update available|NEW EEPROM' <<< "$out"; then
log "EEPROM: aggiornamento disponibile, applico..."
append_report ""
append_report "=== rpi-eeprom-update -a ==="
if rpi-eeprom-update -a >> "$REPORT_FILE" 2>&1; then
NOTES+=("EEPROM aggiornato")
REBOOT_RECOMMENDED=true
else
ERRORS+=("rpi-eeprom-update -a")
fi
else
NOTES+=("EEPROM: aggiornato")
fi
}
audit_docker() {
command -v docker >/dev/null 2>&1 || return 0
append_report ""
append_report "=== Docker audit ==="
local exited
exited=$(docker ps -a --filter "status=exited" --format '{{.Names}} ({{.Image}})' 2>/dev/null || true)
if [[ -n "$exited" ]]; then
WARNINGS+=("Container Docker fermati (Exited): ${exited//$'\n'/, }")
append_report "Exited:"
append_report "$exited"
fi
local name image
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" && continue
if [[ "$image" =~ ^[0-9a-f]{12}$ ]]; then
WARNINGS+=("Container '$name' usa immagine per ID ($image): preferire un tag versionato")
fi
local pinned
for pinned in "${DOCKER_PINNED_IMAGES[@]}"; do
if [[ "$image" == "$pinned" ]]; then
WARNINGS+=("Container '$name' usa tag bloccato ($pinned): Watchtower non passerà a :latest")
fi
done
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
}
audit_pip3() {
[[ "$CHECK_PIP3" == true ]] || return 0
command -v pip3 >/dev/null 2>&1 || return 0
append_report ""
append_report "=== pip3 outdated (sistema) ==="
local outdated count
outdated=$(pip3 list --outdated --format=columns 2>/dev/null | tail -n +3 || true)
if [[ -n "$outdated" ]]; then
count=$(wc -l <<< "$outdated")
append_report "$outdated"
WARNINGS+=("pip3: $count pacchetti Python di sistema non aggiornati (aggiornamento manuale/consigliato in venv)")
else
NOTES+=("pip3: tutti aggiornati")
fi
}
build_telegram_summary() {
local icon status
if ((${#ERRORS[@]} > 0)); then
icon="🚨"
status="ERRORI"
elif ((${#WARNINGS[@]} > 0)); then
icon="⚠️"
status="WARNINGS"
else
icon="✅"
status="OK"
fi
local msg="${icon} *Manutenzione settimanale ${HOST_LABEL}*
Stato: *${status}*
Data: $(date '+%d/%m/%Y %H:%M')"
if [[ "$APT_CHANGED" == true ]]; then
msg+="
📦 apt: pacchetti aggiornati"
fi
if [[ "$DOCKER_UPDATED" == true ]]; then
msg+="
🐳 Docker: container aggiornati"
fi
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
msg+="
🔄 Reboot host programmato tra ${REBOOT_DELAY_MIN} min (fine manutenzione)"
fi
if ((${#ERRORS[@]} > 0)); then
msg+="
*Errori (${#ERRORS[@]}):*"
local e
for e in "${ERRORS[@]}"; do
msg+="
• ${e}"
done
fi
if ((${#WARNINGS[@]} > 0)); then
msg+="
*Avvisi (${#WARNINGS[@]}):*"
local w
local n=0
for w in "${WARNINGS[@]}"; do
((n++)) || true
[[ $n -le 8 ]] && msg+="
• ${w}"
done
if ((${#WARNINGS[@]} > 8)); then
msg+="
• … +$((${#WARNINGS[@]} - 8)) avvisi (vedi report)"
fi
fi
if ((${#ERRORS[@]} == 0 && ${#WARNINGS[@]} == 0)); then
msg+="
Tutto aggiornato. Log: \`${LOG_FILE}\`"
else
msg+="
Report completo allegato o in \`${REPORT_FILE}\`"
fi
send_telegram "$msg"
if ((${#ERRORS[@]} > 0 || ${#WARNINGS[@]} > 0)); then
send_telegram_document
fi
}
# --- Main ---
require_root
: > "$REPORT_FILE"
log "========== Inizio manutenzione settimanale ($HOST_LABEL) =========="
append_report "Manutenzione settimanale - $HOST_LABEL"
append_report "Avviata: $(date '+%Y-%m-%d %H:%M:%S')"
append_report "Host: $(hostname -f) ($(hostname -I | awk '{print $1}'))"
load_telegram_token || true
# 1. Aggiornamento pacchetti sistema
if apt update >> "$REPORT_FILE" 2>&1; then
NOTES+=("apt update OK")
log "✓ apt update"
else
ERRORS+=("apt update")
log "✗ apt update fallito"
fi
BEFORE_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
if apt full-upgrade -y >> "$REPORT_FILE" 2>&1; then
NOTES+=("apt full-upgrade OK")
log "✓ apt full-upgrade"
AFTER_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
if [[ "$BEFORE_UPGRADES" -gt "$AFTER_UPGRADES" ]] || [[ "$BEFORE_UPGRADES" -gt 0 ]]; then
APT_CHANGED=true
REBOOT_RECOMMENDED=true
fi
else
ERRORS+=("apt full-upgrade")
log "✗ apt full-upgrade fallito"
fi
run_step "apt autoremove" apt autoremove -y
audit_apt
# 2. Pi-hole
if [[ -x "$PIHOLE_BIN" ]]; then
run_step "pihole -up" "$PIHOLE_BIN" -up
audit_pihole_versions
else
ERRORS+=("pihole non trovato in $PIHOLE_BIN")
fi
# 3. EEPROM firmware
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
audit_pip3
append_report ""
append_report "=== Riepilogo ==="
append_report "Errori: ${#ERRORS[@]}"
append_report "Avvisi: ${#WARNINGS[@]}"
if ((${#ERRORS[@]} > 0)); then
append_report "Dettaglio errori:"
printf '%s\n' "${ERRORS[@]}" >> "$REPORT_FILE"
fi
if ((${#WARNINGS[@]} > 0)); then
append_report "Dettaglio avvisi:"
printf '%s\n' "${WARNINGS[@]}" >> "$REPORT_FILE"
fi
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
# 5b. Il peer deve poter coprire: niente reboot se è giù o in manutenzione
# (31/10: Pi-1 riavviato alle 04:50 con Pi-2 già giù → rete senza nessun nodo).
REBOOT_GUARD="/home/daniely/rete/scripts/maintenance-reboot-guard.sh"
if [[ "$REBOOT_ON_SUCCESS" == true && -x "$REBOOT_GUARD" ]]; then
if guard_msg="$("$REBOOT_GUARD" check 2>&1)"; then
log "Guardia reboot: ${guard_msg}"
else
REBOOT_ON_SUCCESS=false
WARNINGS+=("Reboot saltato: ${guard_msg}")
log "⚠ Reboot saltato: ${guard_msg}"
fi
fi
build_telegram_summary
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
log "Reboot host programmato tra ${REBOOT_DELAY_MIN} minuti (fine manutenzione settimanale)"
append_report ""
append_report "=== Reboot ==="
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
# Annuncio al peer: prende il VIP ma non pivota le app, e se il nodo non
# torna entro il TTL il suo watchdog fa il failover vero.
[[ -x "$REBOOT_GUARD" ]] && "$REBOOT_GUARD" announce "$REBOOT_DELAY_MIN" || true
/usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
ERRORS+=("shutdown -r (reboot programmato)")
log "✗ Impossibile programmare il reboot"
}
else
log "Reboot disabilitato (--no-reboot o REBOOT_ON_SUCCESS=false)"
append_report ""
append_report "=== Reboot ==="
append_report "Saltato (disabilitato)"
fi
log "========== Fine manutenzione settimanale =========="
exit $(( ${#ERRORS[@]} > 0 ? 1 : 0 ))
-73
View File
@@ -1,73 +0,0 @@
#!/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}"
HOST="${HOSTNAME:-$(hostname)}"
HOST="${HOST%%.*}"
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
return 0 2>/dev/null || exit 0
fi
if (( RC == 0 )); then
TITLE="Backup completato"
BODY="Backup di ${HOST} terminato con successo."
SEVERITY="info"
else
TITLE="Backup fallito"
BODY="Backup di ${HOST} fallito (codice ${RC})."
SEVERITY="error"
fi
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
@@ -1,85 +0,0 @@
# 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
@@ -1,81 +0,0 @@
# 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
-6
View File
@@ -1,6 +0,0 @@
data/
.env
.env.*
!.env.example
*.pyc
__pycache__/
-24
View File
@@ -1,24 +0,0 @@
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
@@ -1,23 +0,0 @@
# 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
@@ -1,34 +0,0 @@
# -*- 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
@@ -1,146 +0,0 @@
# -*- 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.
@@ -1,154 +0,0 @@
# -*- 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
-406
View File
@@ -1,406 +0,0 @@
# -*- coding: utf-8 -*-
"""Filesystem-backed project context store."""
import json
import os
import re
import secrets
import threading
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}$")
# Contesti condivisi: stessa struttura dei progetti personali, sotto un namespace comune.
# Tutta la famiglia li legge e li aggiorna; archiviazione e ritorno a personale solo
# per proprietario o admin.
SHARED_NS = "_shared"
SHARED_WITH_FAMILY = "family"
_SHARED_META_KEYS = ("shared", "shared_with", "owner", "shared_at", "shared_by", "updated_by")
class ConflictError(ValueError):
"""Il contesto è stato modificato da qualcun altro dopo l'ultima lettura."""
# I tool MCP girano in parallelo nel threadpool: serializza le scritture per progetto
_project_locks: dict[tuple[str, str], threading.Lock] = {}
_project_locks_guard = threading.Lock()
def _project_lock(username: str, project_id: str) -> threading.Lock:
with _project_locks_guard:
return _project_locks.setdefault((username, project_id), threading.Lock())
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 _shared_root() -> str:
path = os.path.join(_context_root(), SHARED_NS, "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 _id_taken(project_id: str, username: str, shared: bool) -> bool:
"""Un ID condiviso non deve coincidere con progetti personali di nessun utente
(altrimenti per quell'utente il personale nasconderebbe il condiviso)."""
root = _context_root()
if os.path.exists(os.path.join(_shared_root(), project_id)):
return True
users = [u for u in os.listdir(root) if u != SHARED_NS] if shared else [username]
return any(os.path.exists(os.path.join(root, u, "projects", project_id)) for u in users)
def _read_meta(meta_path: str) -> dict:
with open(meta_path, encoding="utf-8") as f:
return json.load(f)
def _is_member(meta: dict) -> bool:
return meta.get("shared_with") == SHARED_WITH_FAMILY
def _resolve_project(username: str, project_id: str) -> tuple[str, str]:
"""(namespace, cartella) del progetto: prima quello personale, poi quello condiviso."""
personal = _project_dir(username, project_id)
if os.path.isfile(os.path.join(personal, "meta.json")):
return username, personal
shared = os.path.join(_shared_root(), project_id)
meta_path = os.path.join(shared, "meta.json")
if os.path.isfile(meta_path) and _is_member(_read_meta(meta_path)):
return SHARED_NS, shared
raise FileNotFoundError("Progetto non trovato")
def _load_meta(username: str, project_id: str) -> tuple[str, dict]:
_, proj_dir = _resolve_project(username, project_id)
meta_path = os.path.join(proj_dir, "meta.json")
return meta_path, _read_meta(meta_path)
def _require_owner_or_admin(username: str, meta: dict, is_admin: bool, action: str) -> None:
if meta.get("shared") and not is_admin and meta.get("owner") != username:
raise PermissionError(
f"Solo il proprietario ({meta.get('owner')}) o un admin può {action} un contesto condiviso"
)
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 = _resolve_project(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:
"""Progetti personali dell'utente seguiti dai contesti condivisi (campo shared=true)."""
projects = []
for root, shared in ((_user_root(username), False), (_shared_root(), True)):
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 = _read_meta(meta_path)
if shared and not _is_member(meta):
continue
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,
shared: bool = False,
) -> dict:
slug_base = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "progetto"
project_id = slug_base[:40].strip("-")
# ID univoco sia tra i progetti personali sia tra i condivisi (la risoluzione li unisce)
while _id_taken(project_id, username, shared):
project_id = f"{slug_base[:32].strip('-')}-{secrets.token_hex(2)}"
proj_dir = os.path.join(_shared_root() if shared else _user_root(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,
}
if shared:
meta.update(_shared_fields(username, 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 _shared_fields(username: str, now: str) -> dict:
return {
"shared": True,
"shared_with": SHARED_WITH_FAMILY,
"owner": username,
"shared_by": username,
"shared_at": now,
}
def share_project(username: str, project_id: str, shared: bool = True, is_admin: bool = False) -> dict:
"""Sposta un progetto tra lo spazio personale del proprietario e quello condiviso.
La storia (context.md, sessions/, artifacts/) si sposta con il progetto.
"""
ns, proj_dir = _resolve_project(username, project_id)
meta = _read_meta(os.path.join(proj_dir, "meta.json"))
if shared:
if ns == SHARED_NS:
return meta
target = os.path.join(_shared_root(), project_id)
others = [
u for u in os.listdir(_context_root())
if u not in (SHARED_NS, username) and os.path.exists(os.path.join(_context_root(), u, "projects", project_id))
]
if others:
raise ValueError(
f"L'ID '{project_id}' è usato anche da un progetto personale di {', '.join(others)}: "
"crea un contesto condiviso nuovo (create_project shared=true) e copiaci il contenuto"
)
else:
if ns != SHARED_NS:
return meta
_require_owner_or_admin(username, meta, is_admin, "rendere personale")
target = os.path.join(_user_root(meta["owner"]), project_id)
if os.path.exists(target):
raise ValueError(f"Esiste già un progetto '{project_id}' nella destinazione: rinomina prima")
with _project_lock(ns, project_id):
os.rename(proj_dir, target)
meta_path = os.path.join(target, "meta.json")
meta = _read_meta(meta_path)
if shared:
meta.update(_shared_fields(username, datetime.now().astimezone().isoformat(timespec="seconds")))
else:
for key in _SHARED_META_KEYS:
meta.pop(key, None)
_save_meta(meta_path, meta)
return meta
def link_project_repo(
username: str,
project_id: str,
gitea_repo: Optional[str] = None,
seed_from_gitea: bool = False,
) -> dict:
ns, _ = _resolve_project(username, project_id)
with _project_lock(ns, project_id):
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 = _resolve_project(username, project_id)
meta = _read_meta(os.path.join(proj_dir, "meta.json"))
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",
if_revision: Optional[int] = None,
) -> dict:
"""if_revision: meta.revision letto prima di modificare; se nel frattempo qualcuno
ha salvato, ConflictError invece di sovrascrivere (utile nei contesti condivisi)."""
ns, proj_dir = _resolve_project(username, project_id)
with _project_lock(ns, project_id):
return _save_context_locked(username, proj_dir, content, mode, if_revision)
def _save_context_locked(
username: str, proj_dir: str, content: str, mode: str, if_revision: Optional[int]
) -> dict:
meta_path = os.path.join(proj_dir, "meta.json")
if not os.path.isfile(meta_path):
raise FileNotFoundError("Progetto non trovato")
meta = _read_meta(meta_path)
if if_revision is not None and int(meta.get("revision", 0)) != int(if_revision):
raise ConflictError(
f"Contesto modificato (revisione {meta.get('revision', 0)}) il {meta.get('updated_at')}"
+ (f" da {meta['updated_by']}" if meta.get("updated_by") else "")
+ ": rileggilo con get_project_context e riapplica la modifica"
)
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["revision"] = int(meta.get("revision", 0)) + 1
if meta.get("shared"):
meta["updated_by"] = username
_save_meta(meta_path, meta)
# Nei condivisi il nome della sessione dice chi ha scritto
stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + (f"-{username}" if meta.get("shared") else "")
snapshot = stamp + ".md"
n = 1
while os.path.exists(os.path.join(proj_dir, "sessions", snapshot)):
snapshot = f"{stamp}-{n}.md"
n += 1
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, is_admin: bool = False
) -> dict:
ns, _ = _resolve_project(username, project_id)
with _project_lock(ns, project_id):
meta_path, meta = _load_meta(username, project_id)
_require_owner_or_admin(username, meta, is_admin, "archiviare")
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):
ns = SHARED_NS if meta.get("shared") else username
desc = f"Contesto {'condiviso con la famiglia' if meta.get('shared') else 'progetto'} {meta['id']}"
if meta.get("gitea_repo"):
desc += f" (Gitea: {meta['gitea_repo']})"
resources.append(
{
"uri": f"loogle://context/{ns}/{meta['id']}",
"name": meta["title"],
"description": desc,
"mimeType": "text/markdown",
}
)
return resources
def read_resource(username: str, uri: str) -> dict:
for ns in (username, SHARED_NS):
prefix = f"loogle://context/{ns}/"
if uri.startswith(prefix):
project_id = uri[len(prefix):]
break
else:
raise FileNotFoundError("Risorsa non trovata")
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
@@ -1,118 +0,0 @@
# -*- 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)
@@ -1,2 +0,0 @@
# -*- coding: utf-8 -*-
"""Client REST verso app homelab LOOGLE."""
@@ -1,75 +0,0 @@
# -*- 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)
@@ -1,71 +0,0 @@
# -*- 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=httpx.Timeout(30.0, connect=5.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
@@ -1,78 +0,0 @@
# -*- 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)
@@ -1,122 +0,0 @@
# -*- 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=httpx.Timeout(30.0, connect=5.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=httpx.Timeout(60.0, connect=5.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)
@@ -1,208 +0,0 @@
# -*- 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=httpx.Timeout(30.0, connect=5.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=httpx.Timeout(60.0, connect=5.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=httpx.Timeout(30.0, connect=5.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
@@ -1,136 +0,0 @@
# -*- 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.
@@ -1,295 +0,0 @@
# -*- 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_query(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]
@@ -1,172 +0,0 @@
# -*- coding: utf-8 -*-
"""Embedding providers — con thermal gate e keep_alive adattivo."""
import logging
import os
import time
from functools import lru_cache
from typing import Optional
import httpx
from . import thermal
LOGGER = logging.getLogger("loogle_mcp.embeddings")
def embed_query(text: str) -> list[float]:
"""Embedding interattivo per una query di ricerca.
Ordine: OLLAMA_QUERY_URL (istanza dedicata su pi1, ~0.1s, mai in coda dietro
l'indicizzazione) → OLLAMA_URL (DS920, senza thermal gate) → OpenAI.
Timeout corti così il client MCP riceve un errore invece di un hang.
Cache LRU: gli agenti ripetono spesso le stesse query.
"""
return list(_embed_query_cached(text.strip(), os.environ.get("OLLAMA_EMBED_MODEL", "nomic-embed-text")))
@lru_cache(maxsize=256)
def _embed_query_cached(text: str, _model: str) -> tuple[float, ...]:
return tuple(_embed_query_uncached(text))
def _embed_query_uncached(text: str) -> list[float]:
query_url = os.environ.get("OLLAMA_QUERY_URL", "").strip()
ollama_url = os.environ.get("OLLAMA_URL", "").strip()
openai_key = os.environ.get("OPENAI_API_KEY", "").strip()
last_exc: Optional[Exception] = None
if query_url:
try:
# Istanza dedicata: options proprie, il modello resta caricato (keep_alive -1)
return _embed_ollama_query(
text, query_url, read_s=_float_env("OLLAMA_QUERY_URL_TIMEOUT_S", 10.0),
keep_alive=-1, options=_ollama_options("OLLAMA_QUERY_NUM_THREAD"),
)
except Exception as exc:
LOGGER.warning("Ollama query (%s) failed, fallback: %s", query_url, exc)
last_exc = exc
if ollama_url:
try:
return _embed_ollama_query(
text, ollama_url, read_s=_float_env("OLLAMA_QUERY_TIMEOUT_S", 20.0),
keep_alive=int(_float_env("OLLAMA_KEEP_ALIVE_COOL", 300)), options=_ollama_options(),
)
except Exception as exc:
LOGGER.warning("Ollama query embedding failed: %s", exc)
last_exc = exc
if openai_key:
return _embed_openai([text], openai_key, timeout=_float_env("OPENAI_QUERY_TIMEOUT_S", 20.0))[0]
if last_exc is not None:
raise RuntimeError(
f"Servizio embedding (Ollama) non disponibile o lento: {last_exc}. Riprova tra poco."
) from last_exc
raise RuntimeError("Nessun provider embedding configurato (OLLAMA_URL o OPENAI_API_KEY)")
def _float_env(name: str, default: float) -> float:
try:
return float(os.environ.get(name, str(default)))
except ValueError:
return default
def _ollama_options(env_name: str = "OLLAMA_NUM_THREAD") -> Optional[dict]:
# Sulla stessa istanza usare sempre le stesse options: se cambiano Ollama ricarica il modello
num_thread = os.environ.get(env_name, "").strip()
if num_thread:
try:
return {"num_thread": int(num_thread)}
except ValueError:
pass
return None
def _embed_ollama_query(
text: str, base_url: str, *, read_s: float, keep_alive: int, options: Optional[dict]
) -> list[float]:
model = os.environ.get("OLLAMA_EMBED_MODEL", "nomic-embed-text")
timeout = httpx.Timeout(connect=5.0, read=read_s, write=10.0, pool=5.0)
payload = {"model": model, "prompt": text, "keep_alive": keep_alive}
if options:
payload["options"] = options
with httpx.Client(timeout=timeout) as client:
resp = client.post(f"{base_url.rstrip('/')}/api/embeddings", json=payload)
resp.raise_for_status()
return resp.json()["embedding"]
def embed_texts(texts: list[str]) -> list[list[float]]:
if not texts:
return []
# OLLAMA_INDEX_URL: istanza dedicata su pi1 (condivisa con le query). Nessun fallback
# sul DS920 per l'indicizzazione: lì un chunk costa minuti, meglio riprovare al ciclo dopo
index_url = os.environ.get("OLLAMA_INDEX_URL", "").strip()
ollama_url = index_url or os.environ.get("OLLAMA_URL", "").strip()
if ollama_url:
try:
return _embed_ollama(texts, ollama_url, dedicated=bool(index_url))
except Exception as exc:
LOGGER.warning("Ollama embedding failed (%s): %s", ollama_url, exc)
openai_key = os.environ.get("OPENAI_API_KEY", "").strip()
if openai_key:
return _embed_openai(texts, openai_key)
if ollama_url:
raise RuntimeError(f"Ollama non disponibile per l'indicizzazione ({ollama_url})")
raise RuntimeError("Nessun provider embedding configurato (OLLAMA_URL o OPENAI_API_KEY)")
def _embed_ollama(texts: list[str], base_url: str, dedicated: bool = False) -> list[list[float]]:
"""dedicated=True: istanza condivisa con le query → stesse options e modello sempre caricato."""
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 = -1 if dedicated else 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)
options = _ollama_options("OLLAMA_QUERY_NUM_THREAD" if dedicated else "OLLAMA_NUM_THREAD")
if options:
payload["options"] = options
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, timeout: float = 120.0) -> list[list[float]]:
model = os.environ.get("OPENAI_EMBED_MODEL", "text-embedding-3-small")
with httpx.Client(timeout=timeout) 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
@@ -1,495 +0,0 @@
# -*- 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=httpx.Timeout(30.0, connect=5.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}",
}
@@ -1,396 +0,0 @@
# -*- 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")
# File che esistono per contenere segreti: mai indicizzati (le collezioni RAG possono
# essere condivise con la famiglia). .env.example resta indicizzabile.
SECRET_FILE_RE = re.compile(
r"(^|/)(\.env(\.(?!example$)[^/]*)?|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?|\.htpasswd|\.netrc|\.pgpass"
r"|\.claude/settings\.local\.json"
r"|[^/]*\.(pem|key|p12|pfx|jks|keystore))$",
re.IGNORECASE,
)
_SECRET_KEY = r"[A-Za-z0-9_.-]*(?:pass(?:word|wd)?|secret|token|api[_-]?key|private[_-]?key)[A-Za-z0-9_.-]*"
# chiave = valore / chiave: valore (valori letterali, non espressioni di codice)
_SECRET_ASSIGN_RE = re.compile(
rf"(?i)\b({_SECRET_KEY})(\s*[:=]\s*)([\"']?)([^\s\"'#(){{}}\[\]$<>,;]{{4,}})\3(?=[\s\"'#;,]|$)"
)
# stile config "chiave valore" a inizio riga (es. keepalived: auth_pass xxxx)
_SECRET_CONF_RE = re.compile(rf"(?im)^(\s*{_SECRET_KEY})([ \t]+)(\S{{4,}})[ \t]*$")
# password passate a comandi: sshpass -p X, --password X / --password=X
_SECRET_CLI_RE = re.compile(r"(sshpass\s+-p\s*|--password[=\s]\s*)(\\?[\"']?)([^\s\"'\\]{3,})")
_PRIVATE_KEY_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S)
def redact_secrets(text: str) -> str:
"""Maschera i valori segreti prima di embedding e payload Qdrant."""
text = _PRIVATE_KEY_RE.sub("<chiave privata rimossa>", text)
text = _SECRET_CLI_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}<redacted>", text)
text = _SECRET_ASSIGN_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}{m.group(3)}<redacted>{m.group(3)}", text)
return _SECRET_CONF_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}<redacted>", text)
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) or SECRET_FILE_RE.search(path):
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 = redact_secrets((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_query(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)},
}
@@ -1,244 +0,0 @@
# -*- coding: utf-8 -*-
"""Paperless → Qdrant indexing pipeline."""
import logging
import zlib
from concurrent.futures import ThreadPoolExecutor
from typing import Optional
LOGGER = logging.getLogger("loogle_mcp.indexer")
# Indicizzazioni avviate dai tool MCP: fuori dalla richiesta, una alla volta (thermal gate)
_background = ThreadPoolExecutor(max_workers=1, thread_name_prefix="bg-index")
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}"
# crc32 e non hash(): hash() cambia a ogni processo e il delete non trovava i vecchi chunk
doc_id = zlib.crc32(doc_key.encode()) % (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 index_project_context(username: str, project_id: str) -> dict:
"""Indicizza il context.md completo del progetto sostituendo la versione precedente.
Personali → ctx_{utente}; condivisi → ctx_shared_family (visibili a tutta la famiglia).
Così un contesto che cambia (es. inventario) non lascia in ricerca informazioni superate.
"""
from ..context import store as context_store
data = context_store.get_project_context(username, project_id, session_limit=0, include_gitea=False)
meta = data["meta"]
shared = bool(meta.get("shared"))
collection = qdrant_store.CTX_SHARED_COLLECTION if shared else qdrant_store.ctx_collection(username)
namespace = context_store.SHARED_NS if shared else username
drop_project_context(collection, project_id)
chunks = _chunk_text(data["context_md"])
if not chunks:
return {"project_id": project_id, "collection": collection, "chunks": 0}
vectors = embeddings.embed_texts(chunks)
doc_key = f"ctx-{namespace}-{project_id}"
doc_id = zlib.crc32(doc_key.encode()) % (2**31)
ids, payloads = [], []
for i, chunk in enumerate(chunks):
ids.append(f"{doc_key}-chunk-{i}")
payloads.append(
{
"doc_id": doc_id,
"project_id": project_id,
"chunk_index": i,
"title": meta.get("title") or project_id,
"text": chunk,
"owner": meta.get("owner") or username,
"visibility": "family" if shared else "personal",
"shared": shared,
"updated_by": meta.get("updated_by"),
"source": "context",
"snippet_id": "full",
}
)
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
return {"project_id": project_id, "collection": collection, "chunks": len(chunks)}
_DIGEST_SNIPPET_PREFIXES = ("daily-", "plan-")
def drop_project_context(collection: str, project_id: str) -> int:
"""Rimuove tutti i chunk di un progetto (anche quelli indicizzati a pezzi in passato),
tranne gli snippet del digest giornaliero (snippet_id daily-*/plan-*)."""
return qdrant_store.delete_matching(
collection,
"project_id",
project_id,
lambda payload: not str(payload.get("snippet_id") or "").startswith(_DIGEST_SNIPPET_PREFIXES),
)
def submit_background(label: str, fn, *args, **kwargs) -> None:
"""Esegue fn in background; gli errori finiscono nel log invece che al client."""
def _run():
try:
fn(*args, **kwargs)
LOGGER.info("Indicizzazione background completata: %s", label)
except Exception:
LOGGER.exception("Indicizzazione background fallita: %s", label)
_background.submit(_run)
def search_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list:
vectors = [embeddings.embed_query(query)]
collections = [
qdrant_store.SHARED_COLLECTION,
qdrant_store.kb_collection(username),
qdrant_store.ctx_collection(username),
qdrant_store.CTX_SHARED_COLLECTION,
]
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_query(query)]
collections = [qdrant_store.ctx_collection(username), qdrant_store.CTX_SHARED_COLLECTION]
return qdrant_store.search(collections, 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]
@@ -1,140 +0,0 @@
# -*- 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=httpx.Timeout(60.0, connect=5.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=httpx.Timeout(60.0, connect=5.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
@@ -1,307 +0,0 @@
# -*- 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 threading
import time
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
_next_qdrant_check = 0.0 # monotonic: prossimo tentativo di connessione mentre si usa il fallback
_qdrant_lock = threading.Lock()
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():
"""Client Qdrant, oppure None (fallback SQLite).
Se Qdrant non risponde si usa il fallback, ma si riprova ogni QDRANT_RETRY_S secondi
invece di restare sul fallback fino al riavvio.
"""
global _next_qdrant_check
if _qdrant_client is not None or time.monotonic() < _next_qdrant_check:
return _qdrant_client
with _qdrant_lock:
if _qdrant_client is not None or time.monotonic() < _next_qdrant_check:
return _qdrant_client
client = _connect_qdrant()
if client is None:
_next_qdrant_check = time.monotonic() + float(os.environ.get("QDRANT_RETRY_S", "60"))
return client
def _connect_qdrant():
global _qdrant_client
url = os.environ.get("QDRANT_URL", "").strip()
if not url:
LOGGER.warning("QDRANT_URL non impostato — fallback SQLite")
return None
try:
import httpx
from qdrant_client import QdrantClient
from qdrant_client.http import models as qm
# Sonda breve: un tentativo con Qdrant giù non deve bloccare la ricerca per 20s
httpx.get(f"{url.rstrip('/')}/readyz", timeout=3.0).raise_for_status()
client = QdrantClient(url=url, timeout=20)
client.get_collections()
_qdrant_client = client
globals()["qm"] = qm
LOGGER.info("Qdrant connesso: %s", url)
return client
except Exception as exc:
LOGGER.warning("Qdrant non disponibile (%s) — fallback SQLite, nuovo tentativo tra %ss",
exc, os.environ.get("QDRANT_RETRY_S", "60"))
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"
CTX_SHARED_COLLECTION = "ctx_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:
delete_where(collection, "doc_id", doc_id)
def delete_where(collection: str, key: str, value) -> None:
"""Elimina i punti il cui payload[key] == value."""
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=key, match=qm.MatchValue(value=value))]
)
),
)
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(key) == value:
conn.execute("DELETE FROM vectors WHERE id=?", (row_id,))
conn.commit()
def delete_matching(collection: str, key: str, value, predicate) -> int:
"""Elimina i punti con payload[key] == value per cui predicate(payload) è vero."""
client = _get_qdrant()
if client is not None:
from qdrant_client.http import models as qm
ensure_collection(collection)
flt = qm.Filter(must=[qm.FieldCondition(key=key, match=qm.MatchValue(value=value))])
ids, offset = [], None
while True:
points, offset = client.scroll(
collection_name=collection, scroll_filter=flt, limit=256, offset=offset, with_payload=True
)
ids.extend(p.id for p in points if predicate(p.payload or {}))
if offset is None:
break
if ids:
client.delete(collection_name=collection, points_selector=qm.PointIdsList(points=ids))
return len(ids)
conn = _fallback_conn()
rows = conn.execute("SELECT id,payload FROM vectors WHERE collection=?", (collection,)).fetchall()
removed = 0
for row_id, payload_raw in rows:
payload = json.loads(payload_raw)
if payload.get(key) == value and predicate(payload):
conn.execute("DELETE FROM vectors WHERE id=?", (row_id,))
removed += 1
conn.commit()
return removed
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]
@@ -1,22 +0,0 @@
# -*- 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
@@ -1,264 +0,0 @@
# -*- coding: utf-8 -*-
"""Thermal / load gate per proteggere l'host degli embedding Ollama.
Legge temperatura CPU e load average (dell'host locale, pi1, oppure del DS920 via
sonda HTTP/SSH) 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_local() -> Optional[dict]:
"""Temperatura e carico dell'host che esegue gli embedding (pi1): il container
vede /sys/class/thermal e /proc/loadavg dell'host."""
try:
with open("/sys/class/thermal/thermal_zone0/temp", encoding="ascii") as f:
temp = int(f.read().strip()) / 1000
load1, load5, _ = os.getloadavg()
return {"cpu_temp_c": temp, "load1": load1, "load5": load5, "nproc": os.cpu_count() or 4}
except Exception as exc:
LOGGER.debug("Thermal probe locale fallita: %s", exc)
return None
def read_status() -> Optional[dict]:
"""Ritorna {cpu_temp_c, load1, load5?, nproc} oppure None se non raggiungibile.
THERMAL_SOURCE=local legge l'host locale (embedding su pi1); default: DS920.
"""
if os.environ.get("THERMAL_SOURCE", "ds920").strip().lower() == "local":
data = _read_local()
if data:
data["source"] = "local"
return data
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
-503
View File
@@ -1,503 +0,0 @@
# -*- coding: utf-8 -*-
"""Loogle MCP Hub — gateway FastAPI + OAuth + MCP Streamable HTTP."""
import asyncio
import json
import logging
import os
import secrets
from typing import Optional
import anyio
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
from pydantic import BaseModel
from starlette.concurrency import run_in_threadpool
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", "HEAD", "OPTIONS"],
allow_headers=["*"],
)
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
# Thread per tool MCP ed endpoint sync: i tool fanno I/O bloccante (HTTP, SQLite, Ollama)
MCP_THREAD_LIMIT = int(os.environ.get("MCP_THREAD_LIMIT", "40"))
# Sotto il timeout dei client MCP: meglio un errore chiaro che una connessione appesa
MCP_TOOL_TIMEOUT_S = float(os.environ.get("MCP_TOOL_TIMEOUT_S", "45"))
@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)
@app.on_event("startup")
async def configure_threadpool() -> None:
anyio.to_thread.current_default_thread_limiter().total_tokens = MCP_THREAD_LIMIT
# ------------------------------------------------------------------ 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 await run_in_threadpool(
oauth.exchange_code,
body.get("code", ""),
client_id,
body.get("redirect_uri", ""),
body.get("code_verifier"),
)
if grant_type == "refresh_token":
return await run_in_threadpool(
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
_UNAUTHENTICATED_METHODS = ("initialize", "notifications/initialized", "ping")
@app.post("/mcp")
async def mcp_post(request: Request):
header_version = request.headers.get("mcp-protocol-version")
if header_version and header_version not in mcp_server.SUPPORTED_PROTOCOL_VERSIONS:
# Non bloccare: Claude/Anthropic mandano versioni nuove; negoziamo nel body initialize
LOGGER.warning(
"MCP-Protocol-Version header=%s non in lista locale — continuo (negoziazione JSON-RPC)",
header_version,
)
auth_header = request.headers.get("authorization", "")
claims = await run_in_threadpool(oauth.bearer_claims_from_header, auth_header)
try:
payload = await request.json()
except Exception:
raise HTTPException(400, "JSON non valido")
messages = payload if isinstance(payload, list) else [payload]
if not all(isinstance(msg, dict) for msg in messages) or not messages:
return JSONResponse(
{"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}},
status_code=400,
)
if not any(mcp_server.expects_response(msg) for msg in messages):
# Solo notifiche/response dal client: Streamable HTTP prevede 202 senza corpo
if not claims and any(msg.get("method") not in _UNAUTHENTICATED_METHODS for msg in messages):
return Response(status_code=401, headers=_auth_challenge_headers())
return Response(status_code=202)
if isinstance(payload, list):
responses = await _run_with_timeout(mcp_server.handle_batch, payload, claims)
if responses is None:
responses = [_timeout_error(msg) for msg in payload if mcp_server.expects_response(msg)]
return JSONResponse(responses)
response = await _run_with_timeout(mcp_server.handle_message, payload, claims)
if response is None:
response = _timeout_error(payload)
if not claims and payload.get("method") not in _UNAUTHENTICATED_METHODS:
return JSONResponse(response, status_code=401, headers=_auth_challenge_headers())
return JSONResponse(response)
async def _run_with_timeout(fn, payload, claims):
"""I tool sono sincroni e bloccanti: threadpool per non fermare l'event loop.
Allo scadere ritorna None; il thread prosegue fino alla fine ma il client è già servito.
"""
try:
return await asyncio.wait_for(run_in_threadpool(fn, payload, claims), MCP_TOOL_TIMEOUT_S)
except asyncio.TimeoutError:
return None
def _timeout_error(msg: dict) -> dict:
params = msg.get("params") or {}
target = params.get("name") or msg.get("method")
LOGGER.warning("MCP %s oltre %.0fs: risposta di timeout al client", target, MCP_TOOL_TIMEOUT_S)
return {
"jsonrpc": "2.0",
"id": msg.get("id"),
"error": {
"code": -32000,
"message": f"Timeout: '{target}' non ha risposto entro {MCP_TOOL_TIMEOUT_S:.0f}s "
"(backend lento o non raggiungibile). Riprova tra poco.",
},
}
@app.post("/")
async def root_mcp_post(request: Request):
"""Alcuni client (Claude) dopo OAuth POST-ano sulla root invece che su /mcp."""
ctype = (request.headers.get("content-type") or "").lower()
accept = (request.headers.get("accept") or "").lower()
if "json" not in ctype and "json" not in accept and "event-stream" not in accept:
raise HTTPException(404, "Not Found")
return await mcp_post(request)
@app.api_route("/mcp", methods=["GET", "HEAD"])
async def mcp_get(request: Request):
"""Streamable HTTP: GET può aprire SSE; HEAD serve alla discovery di Claude."""
if request.method == "HEAD":
return Response(
status_code=200,
headers={
**_auth_challenge_headers(),
"Allow": "GET, HEAD, POST, OPTIONS",
"Content-Type": "application/json",
},
)
accept = (request.headers.get("accept") or "").lower()
if "text/event-stream" in accept:
# Nessun messaggio server-initiated: stream SSE vuoto che si chiude subito
async def _empty():
yield ": loogle-mcp ready\n\n"
return StreamingResponse(
_empty(),
media_type="text/event-stream",
headers={
**_auth_challenge_headers(),
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
return JSONResponse(
{
"ok": True,
"service": "loogle-mcp",
"transport": "streamable-http",
"endpoint": "/mcp",
"hint": "Usa POST JSON-RPC (initialize, tools/list, tools/call)",
},
headers=_auth_challenge_headers(),
)
@app.api_route("/mcp/", methods=["GET", "HEAD", "POST", "OPTIONS"])
async def mcp_slash(request: Request):
"""Evita redirect HTTP su /mcp/ (NPM) che rompe Claude."""
if request.method == "POST":
return await mcp_post(request)
if request.method == "OPTIONS":
return Response(
status_code=204,
headers={
"Allow": "GET, HEAD, POST, OPTIONS",
"Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id",
},
)
return await mcp_get(request)
@app.options("/mcp")
async def mcp_options():
return Response(
status_code=204,
headers={
"Allow": "GET, HEAD, POST, OPTIONS",
"Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id",
},
)
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.
-97
View File
@@ -1,97 +0,0 @@
# -*- 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")
# Versioni supportate, dalla più recente: si risponde con quella chiesta dal client
# se supportata, altrimenti con la più recente (negoziazione MCP lifecycle)
SUPPORTED_PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
def negotiate_protocol_version(requested: Optional[str]) -> str:
if requested in SUPPORTED_PROTOCOL_VERSIONS:
return requested
# Client più nuovi: accettiamo la richiesta dichiarando la più recente che implementiamo
if requested:
LOGGER.info("protocollo sconosciuto %s — negozio %s", requested, SUPPORTED_PROTOCOL_VERSIONS[0])
return SUPPORTED_PROTOCOL_VERSIONS[0]
def expects_response(msg: Any) -> bool:
"""Solo le request JSON-RPC (method + id) hanno risposta; notifiche e response no."""
return isinstance(msg, dict) and "method" in msg and "id" in msg
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":
requested = params.get("protocolVersion")
version = negotiate_protocol_version(requested)
LOGGER.info(
"initialize: client=%s protocollo richiesto=%s negoziato=%s",
(params.get("clientInfo") or {}).get("name"), requested, version,
)
return _result(
req_id,
{
"protocolVersion": version,
"capabilities": {"tools": {}, "resources": {}},
"serverInfo": {"name": "loogle-mcp", "version": "1.0.0"},
},
)
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 expects_response(msg)]
File diff suppressed because it is too large. Load diff
-322
View File
@@ -1,322 +0,0 @@
# -*- 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)
@@ -1,129 +0,0 @@
<!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>
@@ -1 +0,0 @@
drill-20261031-205709 C0
@@ -1 +0,0 @@
drill-20261031-205709 C1
Binary file not shown.
Whitespace-only changes.
-103
View File
@@ -1,103 +0,0 @@
# Embedding (query + indicizzazione) su Ollama locale di pi1: il DS920 (J4125, senza AVX)
# impiega minuti per chunk. Sul DS920 restano Qdrant (base di conoscenza) e un Ollama di
# riserva per le sole query (OLLAMA_URL in .env). I valori qui sovrascrivono .env.
x-embedding-env: &embedding-env
OLLAMA_QUERY_URL: http://ollama:11434
OLLAMA_INDEX_URL: http://ollama:11434
OLLAMA_QUERY_NUM_THREAD: "2"
# Thermal gate sull'host locale (pi1), soglie da Pi5 (throttling a 80-85°C)
THERMAL_SOURCE: local
THERMAL_TEMP_SOFT_C: "70"
THERMAL_TEMP_HARD_C: "78"
THERMAL_CPU_TARGET_PCT: "75"
THERMAL_POLL_S: "15"
OLLAMA_EMBED_DELAY_S: "0.5"
OLLAMA_EMBED_COOL_DELAY_S: "0"
services:
loogle-mcp:
build:
context: .
dockerfile: Dockerfile
image: loogle-mcp:latest
container_name: loogle-mcp
restart: always
ports:
- "8700:8700"
env_file:
- .env
environment:
<<: *embedding-env
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
depends_on:
- ollama
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:
<<: *embedding-env
TZ: Europe/Rome
MCP_DB: /data/loogle_mcp.db
MCP_CONTEXT_ROOT: /data/context
MCP_VECTOR_FALLBACK: /data/vector_fallback.db
command: ["python", "-m", "worker.indexer_main"]
volumes:
- ./data:/data
- /mnt/ha-apps/mcp/context:/data/context
depends_on:
- loogle-mcp
- ollama
# Ollama per gli embedding (stesso modello del DS920 → stesso spazio vettoriale).
# Nessuna porta pubblicata: raggiungibile solo dai servizi MCP sulla rete compose.
ollama:
image: ollama/ollama:0.32.15
container_name: loogle-mcp-ollama
restart: always
environment:
- OLLAMA_KEEP_ALIVE=-1
- OLLAMA_NUM_PARALLEL=2
- OLLAMA_MAX_LOADED_MODELS=1
volumes:
# Volume Docker (non bind nella cartella progetto: il sorgente viene copiato su Gitea)
- ollama-models:/root/.ollama
cpus: 2
mem_limit: 1g
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 60s
timeout: 10s
retries: 3
# 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
volumes:
ollama-models:
@@ -1,91 +0,0 @@
# 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
```
-322
View File
@@ -1,322 +0,0 @@
# 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; l’accesso 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) │
│ Pi-1 loogle-mcp-ollama (embedding query + indexer) │
└───────┬─────────────────────────────┬───────────────────────┘
│ │
▼ ▼
┌───────────────┐ ┌─────────────────────┐
│ 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` |
| Embedding | Pi-1 | 11434 (solo rete compose) | `loogle-mcp-ollama`, volume `loogle-mcp_ollama-models` |
| Ollama di riserva | DS920 | 11434 | solo query se l'Ollama di Pi-1 non risponde; embedding per extrema |
| 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` | Ollama DS920: riserva per le query, embedding in extrema | `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` |
| `MCP_TOOL_TIMEOUT_S` | Tempo massimo di una chiamata MCP | `45` |
| `MCP_THREAD_LIMIT` | Thread per i tool (eseguiti in parallelo) | `40` |
| `QDRANT_RETRY_S` | Riprova Qdrant ogni N s mentre usa il fallback SQLite | `60` |
| `OLLAMA_QUERY_TIMEOUT_S` | Timeout embedding query sull'Ollama DS920 | `20` |
Impostate in `docker-compose.yml` (sovrascrivono `.env`) per usare l'Ollama di Pi-1:
| Variabile | Valore | Note |
|-----------|--------|------|
| `OLLAMA_QUERY_URL` / `OLLAMA_INDEX_URL` | `http://ollama:11434` | query (~0,1 s) e indicizzazione (~0,9 s/chunk) |
| `OLLAMA_QUERY_NUM_THREAD` | `2` | uguale a `cpus: 2` del container; stesse options per query e indexer, altrimenti Ollama ricarica il modello |
| `THERMAL_SOURCE` | `local` | thermal gate su temperatura e carico di Pi-1 (`ds920` = sonda `DS920_THERMAL_URL`) |
| `THERMAL_TEMP_SOFT_C` / `_HARD_C` | `70` / `78` | soglie Pi 5 |
| `OLLAMA_EMBED_DELAY_S` | `0.5` | pausa tra un chunk e l'altro |
---
## 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 i propri progetti in `/mnt/ha-apps/mcp/context/{username}/` più i
**contesti condivisi** con la famiglia in `/mnt/ha-apps/mcp/context/_shared/`
- Contesti condivisi: tutti li leggono e li aggiornano; archiviazione e ritorno a personale
solo per il proprietario (`owner`) o l'admin. Ogni salvataggio registra `updated_by` e una
sessione `AAAAMMGG-HHMMSS-{utente}.md`; `revision` aumenta a ogni salvataggio
(`save_context if_revision` evita di sovrascrivere modifiche concorrenti)
- I token OAuth JWT contengono `sub` = username; ogni tool filtra per utente
- L’admin 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 personali + condivisi (`shared`) |
| `create_project` | context:write | Nuovo progetto (`shared=true`: condiviso con la famiglia) |
| `share_project` | context:write | Condivide un proprio progetto / lo riporta personale |
| `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}/ # personali
/mnt/ha-apps/mcp/context/_shared/projects/{project-id}/ # condivisi con la famiglia
meta.json # titolo, tag, date, stato, revision, gitea_repo (opz.)
# condivisi: shared, shared_with=family, owner, updated_by
context.md # memoria persistente
sessions/ # snapshot conversazioni (condivisi: suffisso -{utente})
artifacts/ # file generati dagli agenti
```
Gli ID dei condivisi sono univoci anche rispetto ai progetti personali di tutti gli utenti.
Nel vector store ogni progetto è indicizzato con il `context.md` **completo** (sostituito a
ogni salvataggio): `ctx_{utente}` per i personali, `ctx_shared_family` per i condivisi.
---
## 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 Pi-1) → upsert `kb_*`
2. Gitea (repo configurati in `GITEA_INDEX_REPOS_*`) → file `.md`/`.py`/`.sh` → segreti mascherati
(`auth_pass`, `password=`, `token:`, chiavi private) → chunk → upsert `gitea_*`. I file
`.env*` (tranne `.env.example`), chiavi e certificati non vengono mai indicizzati.
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`: riserva per le query ed embedding in extrema
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
@@ -1,209 +0,0 @@
# 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
@@ -1,211 +0,0 @@
# 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 l’AI (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)
@@ -1,237 +0,0 @@
# 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 l’app 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 l’account 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** — l’assistente 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 l’interfaccia
| 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 l’OCR (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** (Wi‑Fi 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 l’app Paperless.
---
## App sul telefono
Paperless **non ha un’app 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 l’app → **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 l’elenco 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 l’app
6. Fatto
### Scansione con il telefono (Paperless Go)
1. Nell’app → **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 nell’app
| Impostazione | Consiglio |
|--------------|-----------|
| **Tema scuro** | Più comodo di sera |
| **Biometria** | Face ID / impronta per aprire l’app |
| **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 l’inbox** — correggi titolo/data se Paperless sbaglia
4. **Non eliminare** l’originale cartaceo finché non sei sicuro che il PDF sia ok
5. **Cambia password** se pensi che qualcuno l’abbia vista
---
## Collegamento con l’assistente AI (MCP)
Se usi ChatGPT, Claude o Cursor collegati a **Loogle MCP** (`https://mcp.loogle.it`):
- Puoi chiedere: *«Cerca in Paperless la bolletta luce dell’ultimo trimestre»*
- L’AI 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.*
-347
View File
@@ -1,347 +0,0 @@
# 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 l’AI produce durante un lavoro, così non devi ripetere tutto ogni volta
I tuoi dati restano **solo sui nostri dispositivi** a casa, non nel cloud dell’AI.
---
## 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é l’AI) 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 l’URL 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 l’accesso
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:
- All’inizio: 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 all’inizio 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 l’irrigazione / 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 dall’app, non solo chiudi la finestra)
3. **Cursor Settings** → **Tools & MCP** → **Connect** su `loogle-mcp` (**una sola volta**, attendi 10–15 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
L’app 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 l’elenco dei **tuoi progetti**
- **Cambiare password**
- Consultare le **ultime azioni** che l’AI ha fatto (audit log)
---
## Come usarlo nel quotidiano
### 1. Crea un progetto per ogni tema importante
Chiedi all’AI:
> «Crea un progetto chiamato "Rinnovo bagno" con tag casa, 2026»
(Usa il tool `create_project` — l’AI 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 l’AI potrà rileggere tutto con `get_project_context`.
### 3. Cerca documenti di casa
> «Cerca in search_knowledge la bolletta luce dell’ultimo 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`.
### 11. Contesti condivisi con la famiglia
Alcuni progetti sono di tutti, per esempio **Stampa 3D famiglia — Bambu Lab X2D**: stampante,
filamenti, piatti, costi e regole di lavoro sono gli stessi per chiunque progetti un oggetto.
Compaiono in `list_projects` con `shared: true` e chiunque li può leggere e aggiornare.
> «Leggi il contesto condiviso della stampa 3D X2D e aiutami a progettare un portapenne»
> «Ho comprato 2 kg di PETG nero a 18 €/kg: aggiorna la dotazione nel contesto X2D»
Quando cambia qualcosa (acquisti, accessori, prezzi, regole) l'assistente aggiorna la sezione
esistente invece di aggiungere note che la contraddicono: da quel momento vale per tutti.
Ogni modifica registra chi l'ha fatta.
Per creare un nuovo contesto condiviso:
> «create_project "Viaggio estate 2027" con shared true»
Per condividere un tuo progetto esistente: *«share_project rinnovo-bagno»*. Solo chi l'ha
condiviso (o Daniele) può archiviarlo o riportarlo personale.
---
## 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
@@ -1,157 +0,0 @@
# 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
@@ -1,108 +0,0 @@
# 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
@@ -1,9 +0,0 @@
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
@@ -1,15 +0,0 @@
#!/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
@@ -1,13 +0,0 @@
#!/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 "$@"
@@ -1,70 +0,0 @@
#!/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()
@@ -1,845 +0,0 @@
#!/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)
- transcript Claude Code di Pi1 (locali) e Pi2 (copiati via rsync in ~/.cache/claude-transcripts/)
- 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"
# Claude Code: <root>/<progetto>/<sessione>.jsonl. Pi2 viene copiato qui prima della lettura.
CLAUDE_LOCAL_ROOT = Path.home() / ".claude/projects"
CLAUDE_REMOTES = [("pi2", "daniely@192.168.128.81")]
CLAUDE_CACHE = Path.home() / ".cache/claude-transcripts"
SYSTEM_TAG_RE = re.compile(r"<(system-reminder|command-[a-z-]+|local-command-[a-z-]+)[^>]*>.*?</\1>", re.DOTALL)
PASTED_TAG_RE = re.compile(r"</?pasted_content[^>]*>")
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 sync_claude_remotes(remotes: list[tuple[str, str]], cache: Path) -> list[tuple[str, Path]]:
"""Copia i transcript Claude Code dei nodi remoti in cache; un nodo giù non blocca il digest."""
roots: list[tuple[str, Path]] = []
for name, host in remotes:
dest = cache / name
dest.mkdir(parents=True, exist_ok=True)
cmd = [
"rsync", "-a", "--timeout=30", "--prune-empty-dirs",
"-e", "ssh -o BatchMode=yes -o ConnectTimeout=6",
"--include=*/", "--exclude=subagents/", "--include=*.jsonl", "--exclude=*",
f"{host}:.claude/projects/", f"{dest}/",
]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
if r.returncode != 0:
print(f"# claude {name}: rsync rc={r.returncode} {r.stderr.strip()[:200]} (uso la copia precedente)",
file=sys.stderr)
except (OSError, subprocess.TimeoutExpired) as e:
print(f"# claude {name}: rsync non riuscito: {e} (uso la copia precedente)", file=sys.stderr)
roots.append((name, dest))
return roots
def _claude_text(content: Any) -> list[str]:
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" and part.get("text"):
out.append(part["text"])
return out
def _clean_claude_user(text: str) -> str:
text = SYSTEM_TAG_RE.sub(" ", text)
text = PASTED_TAG_RE.sub(" ", text)
return text.strip()
def collect_claude_transcripts(day: date, roots: list[tuple[str, Path]], max_chats: int = 25) -> list[dict]:
"""Sessioni Claude Code con almeno un messaggio umano nel giorno (ora locale)."""
start, end = _day_bounds(day)
files: list[tuple[str, Path]] = []
for host, root in roots:
if root.is_dir():
files.extend((host, p) for p in root.glob("*/*.jsonl"))
chats: list[dict] = []
for host, path in sorted(files, key=lambda hp: hp[1].stat().st_mtime, reverse=True):
if datetime.fromtimestamp(path.stat().st_mtime) < start:
continue
title = ""
queries: list[str] = []
tails: list[str] = []
paths_touched: Counter[str] = Counter()
try:
with open(path, encoding="utf-8") as fh:
for line in fh:
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
kind = obj.get("type")
if kind == "custom-title":
title = obj.get("customTitle") or title
continue
if kind not in ("user", "assistant") or obj.get("isSidechain") or obj.get("isMeta"):
continue
try:
when = datetime.fromisoformat(obj["timestamp"].replace("Z", "+00:00")).astimezone().replace(tzinfo=None)
except (KeyError, ValueError):
continue
if not (start <= when < end):
continue
msg = obj.get("message") or {}
content = msg.get("content") if isinstance(msg, dict) else None
if kind == "user":
if obj.get("turnOrigin") not in (None, "human"):
continue
for t in _claude_text(content):
q = _truncate(_clean_claude_user(t), 280)
if q and not _is_noise_query(q):
queries.append(q)
else:
for t in _claude_text(content):
if len(t) > 80:
tails.append(_truncate(t, 320))
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") == "tool_use":
inp = part.get("input") or {}
for key in ("file_path", "path"):
if isinstance(inp.get(key), str):
paths_touched[inp[key]] += 1
except OSError:
continue
if not queries:
continue
seen: set[str] = set()
uniq = [q for q in queries if not (q in seen or seen.add(q))]
chats.append({
"id": path.stem,
"source": f"claude-{host}",
"title": title,
"queries": uniq[:8],
"conclusions": 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 + Claude Code)")
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.get("title") or (chat["queries"][0] if chat["queries"] else chat["id"])
lines.append(f"{i}. **{_truncate(title, 120)}** `[{chat.get('source', 'cursor')} {chat['id'][:8]}]`")
for q in chat["queries"][(0 if chat.get("title") else 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-claude", action="store_true", help="Non leggere i transcript Claude Code")
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)
if not args.no_claude:
claude_roots = [("pi1", CLAUDE_LOCAL_ROOT)] + sync_claude_remotes(CLAUDE_REMOTES, CLAUDE_CACHE)
chats += collect_claude_transcripts(day, claude_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())
@@ -1,721 +0,0 @@
#!/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())
@@ -1,94 +0,0 @@
#!/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())
@@ -1,68 +0,0 @@
#!/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())
@@ -1,26 +0,0 @@
#!/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']}")
@@ -1,96 +0,0 @@
#!/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())
@@ -1,258 +0,0 @@
#!/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())
@@ -1,111 +0,0 @@
#!/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())
@@ -1,130 +0,0 @@
#!/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())
@@ -1,92 +0,0 @@
#!/usr/bin/env python3
"""Test di concorrenza MCP: N chiamate parallele + sonde /health e ping.
Uso (dentro il container):
docker exec loogle-mcp python /srv/scripts/test_mcp_concurrency.py [N] [tool]
Misura se una chiamata lenta blocca le altre (event loop bloccato) e la
latenza delle ricerche semantiche.
"""
import concurrent.futures as cf
import json
import os
import sys
import time
import httpx
sys.path.insert(0, "/srv")
from app.db import get_conn # noqa: E402
from app.jwt_utils import create_access_token # noqa: E402
BASE = os.environ.get("TEST_BASE", "http://127.0.0.1:8700")
N = int(sys.argv[1]) if len(sys.argv) > 1 else 4
TOOL = sys.argv[2] if len(sys.argv) > 2 else "search_knowledge"
CLIENT_TIMEOUT = 150.0
row = get_conn().execute("SELECT id, username FROM users WHERE username='daniele'").fetchone()
token, _ = create_access_token(
{"id": row["id"], "username": row["username"]},
"context:read knowledge:read gitea:read admin",
"loadtest",
)
HEADERS = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
def rpc(method: str, params: dict, req_id: int) -> tuple[float, str]:
t0 = time.monotonic()
try:
r = httpx.post(
f"{BASE}/mcp",
headers=HEADERS,
json={"jsonrpc": "2.0", "id": req_id, "method": method, "params": params},
timeout=CLIENT_TIMEOUT,
)
body = r.json()
if "error" in body:
status = f"ERR {body['error'].get('code')}: {body['error'].get('message', '')[:90]}"
else:
text = body["result"]["content"][0]["text"] if method == "tools/call" else "{}"
payload = json.loads(text)
n = len(payload.get("results", [])) if isinstance(payload, dict) else "?"
status = f"OK results={n}"
except Exception as exc: # noqa: BLE001
status = f"EXC {type(exc).__name__}: {exc}"[:100]
return time.monotonic() - t0, status
def health() -> float:
t0 = time.monotonic()
try:
httpx.get(f"{BASE}/health", timeout=CLIENT_TIMEOUT)
except Exception: # noqa: BLE001
return -1.0
return time.monotonic() - t0
def main() -> None:
queries = ["failover keepalived", "irrigazione zone", "turni medici", "backup nas",
"runbook", "rete vlan", "home assistant", "paperless"]
t_start = time.monotonic()
with cf.ThreadPoolExecutor(max_workers=N + 2) as pool:
futs = {
pool.submit(rpc, "tools/call",
{"name": TOOL, "arguments": {"query": queries[i % len(queries)], "limit": 3}},
i + 1): f"{TOOL}#{i + 1}"
for i in range(N)
}
time.sleep(0.5)
h = pool.submit(health)
p = pool.submit(rpc, "tools/call", {"name": "ping", "arguments": {}}, 999)
print(f"/health durante il carico: {h.result():.2f}s")
pt, ps = p.result()
print(f"ping durante il carico: {pt:.2f}s {ps}")
for fut in cf.as_completed(futs):
dt, status = fut.result()
print(f"{futs[fut]:<24} {dt:6.2f}s {status}")
print(f"Totale: {time.monotonic() - t_start:.2f}s (N={N})")
if __name__ == "__main__":
main()
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
"""Conformità Streamable HTTP / lifecycle MCP: negoziazione versione, 202 per notifiche, header.
Uso (dentro il container):
docker exec loogle-mcp python /srv/scripts/test_mcp_protocol.py
"""
import os
import sys
import httpx
sys.path.insert(0, "/srv")
from app.db import get_conn # noqa: E402
from app.jwt_utils import create_access_token # noqa: E402
BASE = os.environ.get("TEST_BASE", "http://127.0.0.1:8700") + "/mcp"
row = get_conn().execute("SELECT id, username FROM users WHERE username='daniele'").fetchone()
token, _ = create_access_token(dict(row), "context:read", "protocol-test")
AUTH = {"Authorization": f"Bearer {token}"}
failures = 0
def check(label: str, cond: bool, detail: str = "") -> None:
global failures
failures += 0 if cond else 1
print(f"{'OK ' if cond else 'FAIL'} {label} {detail}")
def init(version):
params = {"protocolVersion": version, "capabilities": {}, "clientInfo": {"name": "protocol-test"}}
r = httpx.post(BASE, json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": params})
return r.json()["result"]["protocolVersion"]
for requested, expected in [("2025-06-18", "2025-06-18"), ("2025-03-26", "2025-03-26"),
("2024-11-05", "2024-11-05"), ("2099-01-01", "2025-06-18")]:
got = init(requested)
check(f"initialize {requested}", got == expected, f"-> {got}")
r = httpx.post(BASE, json={"jsonrpc": "2.0", "method": "notifications/initialized"})
check("notifica non autenticata initialized -> 202", r.status_code == 202 and not r.content, str(r.status_code))
r = httpx.post(BASE, headers=AUTH, json={"jsonrpc": "2.0", "method": "notifications/cancelled",
"params": {"requestId": 5}})
check("notifica cancelled -> 202", r.status_code == 202, str(r.status_code))
r = httpx.post(BASE, json={"jsonrpc": "2.0", "method": "notifications/cancelled"})
check("notifica non autenticata non ammessa -> 401", r.status_code == 401, str(r.status_code))
r = httpx.post(BASE, headers=AUTH, json=[{"jsonrpc": "2.0", "method": "notifications/initialized"},
{"jsonrpc": "2.0", "id": 7, "method": "ping"}])
body = r.json()
check("batch notifica+request -> solo 1 risposta", r.status_code == 200 and len(body) == 1 and body[0]["id"] == 7,
str(body))
r = httpx.post(BASE, headers={**AUTH, "MCP-Protocol-Version": "2025-06-18"},
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
check("header versione valida -> 200", r.status_code == 200 and "tools" in r.json()["result"], str(r.status_code))
r = httpx.post(BASE, headers={**AUTH, "MCP-Protocol-Version": "1999-01-01"},
json={"jsonrpc": "2.0", "id": 3, "method": "tools/list"})
check("header versione non supportata -> 400", r.status_code == 400, str(r.status_code))
r = httpx.post(BASE, json={"jsonrpc": "2.0", "id": 4, "method": "tools/list"})
check("request senza token -> 401 + WWW-Authenticate",
r.status_code == 401 and "resource_metadata" in r.headers.get("www-authenticate", ""), str(r.status_code))
r = httpx.post(BASE, headers=AUTH, json=[])
check("batch vuoto -> 400", r.status_code == 400, str(r.status_code))
print(f"Errori: {failures}")
sys.exit(1 if failures else 0)
@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""Regressione: chiama ogni tool MCP senza argomenti obbligatori (sola lettura) e ne misura la durata.
Uso (dentro il container):
docker exec loogle-mcp python /srv/scripts/test_mcp_readonly_tools.py
"""
import json
import os
import sys
import time
import httpx
sys.path.insert(0, "/srv")
from app.db import get_conn # noqa: E402
from app.jwt_utils import create_access_token # noqa: E402
BASE = os.environ.get("TEST_BASE", "http://127.0.0.1:8700")
# Tool senza argomenti obbligatori ma con effetti collaterali: esclusi
SKIP = {"reindex_apps"}
row = get_conn().execute("SELECT id, username FROM users WHERE username='daniele'").fetchone()
scope = ("context:read knowledge:read gitea:read home:read irrigation:read turni:read admin")
token, _ = create_access_token({"id": row["id"], "username": row["username"]}, scope, "regression")
HEADERS = {"Authorization": f"Bearer {token}"}
def rpc(method: str, params: dict) -> dict:
r = httpx.post(f"{BASE}/mcp", headers=HEADERS, timeout=120,
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
return r.json()
tools = rpc("tools/list", {})["result"]["tools"]
failures = 0
for tool in tools:
if tool["inputSchema"].get("required") or tool["name"] in SKIP:
continue
t0 = time.monotonic()
body = rpc("tools/call", {"name": tool["name"], "arguments": {}})
dt = time.monotonic() - t0
if "error" in body:
status = f"ERR {body['error']['message'][:80]}"
failures += 1
else:
status = f"OK {len(body['result']['content'][0]['text'])} bytes"
print(f"{tool['name']:<30} {dt:6.2f}s {status}")
print(f"Tool totali: {len(tools)}, errori: {failures}")
@@ -1,70 +0,0 @@
#!/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())
@@ -1,74 +0,0 @@
#!/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())
@@ -1,83 +0,0 @@
#!/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())
@@ -1,25 +0,0 @@
#!/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")
@@ -1,154 +0,0 @@
#!/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())
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""Test dei contesti condivisi su directory e DB temporanei (non tocca i dati reali).
Uso (dentro il container):
docker exec loogle-mcp python /srv/scripts/test_shared_contexts.py
"""
import json
import os
import sys
import tempfile
TMP = tempfile.mkdtemp(prefix="shared-ctx-test-")
os.environ["MCP_CONTEXT_ROOT"] = os.path.join(TMP, "context")
os.environ["MCP_DB"] = os.path.join(TMP, "test.db")
sys.path.insert(0, "/srv")
from app.db import init_db # noqa: E402
from app.context import store # noqa: E402
from app.knowledge import indexer # noqa: E402
from app.mcp import tools # noqa: E402
init_db()
queued = []
indexer.submit_background = lambda label, fn, *a, **k: queued.append((label, fn.__name__, a))
failures = 0
def claims(user, admin=False):
scope = "context:read context:write" + (" admin" if admin else "")
return {"sub": user, "scope": scope}
def call(user, name, args, admin=False):
out = tools.call_tool(name, args, claims(user, admin))
return json.loads(out["content"][0]["text"])
def check(label, cond):
global failures
failures += 0 if cond else 1
print(f"{'OK ' if cond else 'FAIL'} {label}")
def raises(exc, fn, *a, **k):
try:
fn(*a, **k)
except exc:
return True
except Exception as other: # noqa: BLE001
print(" eccezione inattesa:", type(other).__name__, other)
return False
# 1. Progetto personale di daniele, poi condiviso
p = call("daniele", "create_project", {"title": "Stampa 3D X2D", "tags": ["stampa-3d"]})
pid = p["id"]
call("daniele", "save_context", {"project_id": pid, "content": "## Dotazione\n- PLA 4 kg"})
check("lucia non vede il progetto personale di daniele",
pid not in [x["id"] for x in call("lucia", "list_projects", {})["projects"]])
check("lucia non può leggerlo", raises(FileNotFoundError, call, "lucia", "get_project_context", {"project_id": pid}))
m = call("daniele", "share_project", {"project_id": pid})
check("share_project -> shared/family/owner", m.get("shared") and m.get("shared_with") == "family" and m.get("owner") == "daniele")
check("la cartella è passata nello spazio condiviso",
os.path.isfile(os.path.join(TMP, "context", "_shared", "projects", pid, "context.md"))
and not os.path.exists(os.path.join(TMP, "context", "daniele", "projects", pid)))
check("reindicizzazione vettori accodata", any(q[1] == "_move_project_vectors" for q in queued))
# 2. Tutta la famiglia legge e aggiorna
for u in ("lucia", "davide", "luca"):
ids = {x["id"]: x for x in call(u, "list_projects", {})["projects"]}
check(f"{u} vede il contesto condiviso", pid in ids and ids[pid].get("shared"))
ctx = call("lucia", "get_project_context", {"project_id": pid})
check("lucia legge la storia (sessione di daniele)", "PLA 4 kg" in ctx["context_md"] and ctx["recent_sessions"])
rev = ctx["meta"].get("revision", 0)
m = call("lucia", "save_context", {"project_id": pid, "mode": "replace",
"content": "## Dotazione\n- PLA 4 kg\n- PETG 1 kg (comprato da Lucia)",
"if_revision": rev})
check("lucia aggiorna, updated_by=lucia", m.get("updated_by") == "lucia")
check("daniele vede la modifica di lucia", "PETG 1 kg" in call("daniele", "get_project_context", {"project_id": pid})["context_md"])
sessions = os.listdir(os.path.join(TMP, "context", "_shared", "projects", pid, "sessions"))
check("il nome della sessione indica l'autore", any(s.endswith("-lucia.md") for s in sessions))
check("conflitto: if_revision superata -> errore e nessuna sovrascrittura",
raises(store.ConflictError, store.save_context, "davide", pid, "x", "replace", if_revision=rev)
and "PETG 1 kg" in call("davide", "get_project_context", {"project_id": pid})["context_md"])
check("indicizzazione del contesto completo accodata", any(q[1] == "index_project_context" for q in queued))
# 3. Permessi: archiviare / rendere personale
check("lucia non può archiviare", raises(PermissionError, call, "lucia", "archive_project", {"project_id": pid}))
check("lucia non può riportarlo personale", raises(PermissionError, call, "lucia", "share_project", {"project_id": pid, "shared": False}))
check("admin può archiviare", call("davide", "archive_project", {"project_id": pid}, admin=True).get("archived") is True)
call("daniele", "archive_project", {"project_id": pid, "archived": False})
m = call("daniele", "share_project", {"project_id": pid, "shared": False})
check("il proprietario lo riporta personale", not m.get("shared") and "owner" not in m)
check("dopo: lucia non lo vede più", pid not in [x["id"] for x in call("lucia", "list_projects", {})["projects"]])
check("dopo: daniele lo ha di nuovo personale con la storia",
"PETG 1 kg" in call("daniele", "get_project_context", {"project_id": pid})["context_md"])
# 4. Creazione diretta condivisa, ID unici tra i due spazi, risorse MCP
s = call("lucia", "create_project", {"title": "Stampa 3D X2D", "shared": True})
check("create_project shared=true con ID diverso dal personale di daniele", s.get("shared") and s["id"] != pid)
check("daniele raggiunge sia il suo personale sia il nuovo condiviso",
call("daniele", "get_project_context", {"project_id": s["id"]})["meta"].get("shared")
and not call("daniele", "get_project_context", {"project_id": pid})["meta"].get("shared"))
call("luca", "create_project", {"title": "Omonimo"})
call("daniele", "create_project", {"title": "Omonimo"})
check("share_project rifiuta un ID usato da un personale di un altro utente",
raises(ValueError, call, "daniele", "share_project", {"project_id": "omonimo"}))
uris = [r["uri"] for r in tools.list_resources(claims("luca"))]
check("risorsa condivisa visibile a luca", f"loogle://context/_shared/{s['id']}" in uris)
res = tools.read_resource(f"loogle://context/_shared/{s['id']}", claims("luca"))
check("luca legge la risorsa condivisa", res["text"].startswith("# Stampa 3D X2D"))
print(f"Errori: {failures}")
sys.exit(1 if failures else 0)
@@ -1,37 +0,0 @@
#!/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)
@@ -1,34 +0,0 @@
#!/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)
@@ -1,86 +0,0 @@
# -*- 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()
+4 -32
View File
@@ -192,15 +192,6 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
message_html: Messaggio HTML da inviare message_html: Messaggio HTML da inviare
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
""" """
try:
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip arome_snow")
return False
token = load_bot_token() token = load_bot_token()
if not token: if not token:
LOGGER.warning("Telegram token missing: message not sent.") LOGGER.warning("Telegram token missing: message not sent.")
@@ -261,15 +252,6 @@ def telegram_send_photo(photo_path: str, caption: str, chat_ids: Optional[List[s
Returns: Returns:
True se inviata con successo, False altrimenti True se inviata con successo, False altrimenti
""" """
try:
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip arome_snow photo")
return False
token = load_bot_token() token = load_bot_token()
if not token: if not token:
LOGGER.warning("Telegram token missing: photo not sent.") LOGGER.warning("Telegram token missing: photo not sent.")
@@ -337,7 +319,7 @@ def load_state() -> Dict:
return default return default
def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = None, last_notification_utc: Optional[str] = None, summary: Optional[str] = None) -> None: def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = None, last_notification_utc: Optional[str] = None) -> None:
try: try:
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
state_data = { state_data = {
@@ -354,8 +336,6 @@ def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = N
"casa_first_thr_time": casa_data.get("first_thr_time", ""), "casa_first_thr_time": casa_data.get("first_thr_time", ""),
"casa_duration_hours": casa_data.get("duration_hours", 0.0), "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: with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state_data, f, ensure_ascii=False, indent=2) json.dump(state_data, f, ensure_ascii=False, indent=2)
except Exception as e: except Exception as e:
@@ -1696,15 +1676,7 @@ def analyze_snow(chat_ids: Optional[List[str]] = None, debug_mode: bool = False)
msg.append("<i>Fonte dati: Open-Meteo</i>") msg.append("<i>Fonte dati: Open-Meteo</i>")
# Unisci con <br> (sarà convertito in \n in telegram_send_html) # Unisci con <br> (sarà convertito in \n in telegram_send_html)
html_msg = "<br>".join(msg) ok = telegram_send_html("<br>".join(msg), chat_ids=chat_ids)
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) # Genera e invia grafico (solo se abbiamo dati per Casa)
chart_generated = False chart_generated = False
@@ -1741,11 +1713,11 @@ def analyze_snow(chat_ids: Optional[List[str]] = None, debug_mode: bool = False)
# Salva timestamp dell'ultima notifica # Salva timestamp dell'ultima notifica
now_utc = datetime.datetime.now(datetime.timezone.utc) now_utc = datetime.datetime.now(datetime.timezone.utc)
save_state(True, sig, casa_data, last_notification_utc=now_utc.isoformat(timespec="seconds"), summary=snow_summary) save_state(True, sig, casa_data, last_notification_utc=now_utc.isoformat(timespec="seconds"))
else: else:
LOGGER.warning("Notifica neve NON inviata (token mancante o errore Telegram).") LOGGER.warning("Notifica neve NON inviata (token mancante o errore Telegram).")
# Salva comunque lo state (senza aggiornare last_notification_utc) # Salva comunque lo state (senza aggiornare last_notification_utc)
save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", ""), summary=snow_summary) save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", ""))
else: else:
LOGGER.info("Allerta attiva ma nessun cambiamento significativo. Motivo: %s", change_reason) 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) # Salva lo state anche se non inviamo (per mantenere alert_active e last_notification_utc)
-112
View File
@@ -1,112 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Basemap tiles for matplotlib maps (road weather, ice, snow radar).
Prefer tile sources that do **not** require an API key:
- OpenStreetMap Mapnik — OK with an identifying User-Agent
(contextily's default UA gets "403 Access blocked" tiles)
- Esri WorldStreetMap — free for reasonable use, good road detail
- OpenTopoMap — terrain fallback
CARTO basemaps are avoided: they now overlay "API KEY REQUIRED".
Compatible with contextily 1.6.x (loogle-casa) and 1.7.x (loogle-bot):
in 1.6, ``headers=`` is forwarded to ``imshow`` and breaks the call,
so we set ``contextily.tile.USER_AGENT`` instead.
"""
from __future__ import annotations
import inspect
import logging
from typing import Any, Optional
LOGGER = logging.getLogger("basemap_tiles")
BASEMAP_USER_AGENT = "LoogleCasa/1.0 (https://casa.loogle.it; meteo maps)"
BASEMAP_HEADERS = {"User-Agent": BASEMAP_USER_AGENT}
def _configure_user_agent() -> None:
try:
import contextily.tile as tile_mod
tile_mod.USER_AGENT = BASEMAP_USER_AGENT
except Exception as exc: # noqa: BLE001
LOGGER.debug("Impossibile impostare USER_AGENT contextily: %s", exc)
def _providers(ctx) -> list:
"""Ordered list of free (no API key) basemap providers."""
out = []
candidates = [
("OpenStreetMap", "Mapnik"),
("Esri", "WorldStreetMap"),
("OpenTopoMap",),
("OpenStreetMap", "DE"),
("OpenStreetMap", "HOT"),
]
for path in candidates:
try:
node = ctx.providers
for part in path:
node = getattr(node, part)
# skip leaf namespaces that aren't a provider dict
if hasattr(node, "url") or (isinstance(node, dict) and "url" in node):
out.append(node)
elif path == ("OpenTopoMap",):
# xyzservices LeafMap style
out.append(node)
except AttributeError:
continue
return out
def add_basemap(
ax: Any,
*,
alpha: float = 0.85,
attribution_size: int = 6,
crs: str = "EPSG:4326",
) -> bool:
"""Add a web basemap under *ax*. Returns True on success."""
try:
import contextily as ctx
except ImportError:
LOGGER.warning("contextily non disponibile: mappa senza basemap")
return False
_configure_user_agent()
providers = _providers(ctx)
if not providers:
LOGGER.warning("Nessun provider basemap trovato in contextily")
return False
call_kwargs: dict = {
"crs": crs,
"alpha": alpha,
"attribution_size": attribution_size,
}
try:
if "headers" in inspect.signature(ctx.add_basemap).parameters:
call_kwargs["headers"] = BASEMAP_HEADERS
except (TypeError, ValueError):
pass
last_err: Optional[BaseException] = None
for source in providers:
name = getattr(source, "name", None) or (
source.get("name") if isinstance(source, dict) else str(source)
)
try:
ctx.add_basemap(ax, source=source, **call_kwargs)
LOGGER.info("Basemap OK: %s", name)
return True
except Exception as exc: # noqa: BLE001
last_err = exc
LOGGER.warning("Basemap %s fallita: %s", name, exc)
if last_err:
LOGGER.warning("Nessuna basemap disponibile: %s", last_err)
return False
+1 -23
View File
@@ -758,14 +758,6 @@ async def meteo_viaggio_command(update: Update, context: ContextTypes.DEFAULT_TY
async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None: async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None:
# Stesso comportamento di `/meteo` senza argomenti: Casa (+ viaggio se attivo) per utente. # 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"]) report_casa = call_meteo_script(["--home"])
for uid in ALLOWED_IDS: for uid in ALLOWED_IDS:
chat_id = str(uid) chat_id = str(uid)
@@ -900,21 +892,7 @@ def main():
application.add_handler(CallbackQueryHandler(button_handler)) application.add_handler(CallbackQueryHandler(button_handler))
job_queue = application.job_queue job_queue = application.job_queue
# Report meteo quotidiano 07:15 — sospeso se LOOGLE_TELEGRAM_ALERTS=0 (cron-alerts.env) job_queue.run_daily(scheduled_morning_report, time=datetime.time(hour=7, minute=15, tzinfo=TZINFO), days=(0, 1, 2, 3, 4, 5, 6))
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() application.run_polling()
+31 -70
View File
@@ -78,7 +78,7 @@ def get_bot_token():
sys.exit(1) sys.exit(1)
def save_current_state(state, report_meta=None, summary=None): def save_current_state(state, report_meta=None):
try: try:
# Aggiungi timestamp corrente per tracciare quando è stato salvato lo stato # Aggiungi timestamp corrente per tracciare quando è stato salvato lo stato
if report_meta is None: if report_meta is None:
@@ -87,19 +87,7 @@ def save_current_state(state, report_meta=None, summary=None):
"points": state, "points": state,
"last_update": datetime.datetime.now().isoformat(), "last_update": datetime.datetime.now().isoformat(),
"report_meta": report_meta, "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: with open(STATE_FILE, 'w') as f:
json.dump(state_with_meta, f) json.dump(state_with_meta, f)
except Exception as e: except Exception as e:
@@ -1631,13 +1619,6 @@ def format_route_ice_report(df: pd.DataFrame, city1: str, city2: str) -> str:
return msg return msg
def send_telegram_broadcast(token, message, debug_mode=False): def send_telegram_broadcast(token, message, debug_mode=False):
try:
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
if not telegram_alerts_enabled():
return
base_url = f"https://api.telegram.org/bot{token}/sendMessage" base_url = f"https://api.telegram.org/bot{token}/sendMessage"
recipients = [ADMIN_CHAT_ID] if debug_mode else TELEGRAM_CHAT_IDS recipients = [ADMIN_CHAT_ID] if debug_mode else TELEGRAM_CHAT_IDS
@@ -1673,14 +1654,13 @@ def generate_route_ice_map(df: pd.DataFrame, city1: str, city2: str, output_path
print(f"matplotlib non disponibile: {e}. Mappa non generata.") print(f"matplotlib non disponibile: {e}. Mappa non generata.")
return False return False
# Prova a importare basemap (CARTO / OSM HOT; non tile.openstreetmap.org) # Prova a importare contextily per mappa di sfondo
try: try:
from basemap_tiles import add_basemap import contextily as ctx
CONTEXTILY_AVAILABLE = True CONTEXTILY_AVAILABLE = True
except ImportError: except ImportError:
CONTEXTILY_AVAILABLE = False CONTEXTILY_AVAILABLE = False
add_basemap = None # type: ignore print("contextily non disponibile. Mappa generata senza sfondo geografico.")
print("contextily/basemap_tiles non disponibile. Mappa generata senza sfondo geografico.")
if df.empty: if df.empty:
return False return False
@@ -1721,10 +1701,13 @@ def generate_route_ice_map(df: pd.DataFrame, city1: str, city2: str, output_path
ax.set_ylim(lat_min, lat_max) ax.set_ylim(lat_min, lat_max)
ax.set_aspect('equal', adjustable='box') ax.set_aspect('equal', adjustable='box')
# Aggiungi mappa di sfondo (CARTO; OSM Mapnik è bloccato per lo UA di contextily) # Aggiungi mappa di sfondo OpenStreetMap se disponibile
if CONTEXTILY_AVAILABLE and add_basemap is not None: if CONTEXTILY_AVAILABLE:
if not add_basemap(ax, alpha=0.85, attribution_size=6): try:
print("Errore aggiunta mappa sfondo") ctx.add_basemap(ax, crs='EPSG:4326', source=ctx.providers.OpenStreetMap.Mapnik,
alpha=0.6, attribution_size=6)
except Exception as e:
print(f"Errore aggiunta mappa sfondo: {e}")
CONTEXTILY_AVAILABLE = False CONTEXTILY_AVAILABLE = False
# Disegna linea del percorso # Disegna linea del percorso
@@ -1810,13 +1793,6 @@ def generate_route_ice_map(df: pd.DataFrame, city1: str, city2: str, output_path
def send_telegram_photo(token, photo_path, caption, debug_mode=False): def send_telegram_photo(token, photo_path, caption, debug_mode=False):
"""Invia foto via Telegram API.""" """Invia foto via Telegram API."""
try:
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
if not telegram_alerts_enabled():
return False
if not os.path.exists(photo_path): if not os.path.exists(photo_path):
return False return False
@@ -1869,14 +1845,13 @@ def generate_ice_risk_map(points_data: List[Dict], output_path: str) -> bool:
print(f"matplotlib non disponibile: {e}. Mappa non generata.") print(f"matplotlib non disponibile: {e}. Mappa non generata.")
return False return False
# Prova a importare basemap (CARTO / OSM HOT; non tile.openstreetmap.org) # Prova a importare contextily per mappa di sfondo
try: try:
from basemap_tiles import add_basemap import contextily as ctx
CONTEXTILY_AVAILABLE = True CONTEXTILY_AVAILABLE = True
except ImportError: except ImportError:
CONTEXTILY_AVAILABLE = False CONTEXTILY_AVAILABLE = False
add_basemap = None # type: ignore print("contextily non disponibile. Mappa generata senza sfondo geografico.")
print("contextily/basemap_tiles non disponibile. Mappa generata senza sfondo geografico.")
if not points_data: if not points_data:
return False return False
@@ -1900,10 +1875,13 @@ def generate_ice_risk_map(points_data: List[Dict], output_path: str) -> bool:
ax.set_ylim(lat_min, lat_max) ax.set_ylim(lat_min, lat_max)
ax.set_aspect('equal', adjustable='box') ax.set_aspect('equal', adjustable='box')
# Aggiungi mappa di sfondo (CARTO; OSM Mapnik è bloccato per lo UA di contextily) # Aggiungi mappa di sfondo OpenStreetMap se disponibile
if CONTEXTILY_AVAILABLE and add_basemap is not None: if CONTEXTILY_AVAILABLE:
if not add_basemap(ax, alpha=0.85, attribution_size=6): try:
print("Errore aggiunta mappa sfondo") ctx.add_basemap(ax, crs='EPSG:4326', source=ctx.providers.OpenStreetMap.Mapnik,
alpha=0.6, attribution_size=6)
except Exception as e:
print(f"Errore aggiunta mappa sfondo: {e}")
CONTEXTILY_AVAILABLE = False CONTEXTILY_AVAILABLE = False
# Colori per livelli di rischio: verde (0), giallo (1), arancione (2), rosso scuro (3), azzurro (4=neve) # Colori per livelli di rischio: verde (0), giallo (1), arancione (2), rosso scuro (3), azzurro (4=neve)
@@ -2259,26 +2237,7 @@ def main():
append_report(new_alerts, improvement_msg, important, report_meta, DEBUG_MODE) append_report(new_alerts, improvement_msg, important, report_meta, DEBUG_MODE)
# Genera e invia mappa solo quando ci sono aggiornamenti # Genera e invia mappa solo quando ci sono aggiornamenti
ice_summary = None
if new_alerts or solved_alerts: 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: if DEBUG_MODE:
print(f"Generazione mappa per {len(map_points_data)} punti...") print(f"Generazione mappa per {len(map_points_data)} punti...")
map_path = os.path.join(SCRIPT_DIR, "ice_risk_map.png") map_path = os.path.join(SCRIPT_DIR, "ice_risk_map.png")
@@ -2292,16 +2251,18 @@ def main():
f"🕒 {now.strftime('%d/%m/%Y %H:%M')}\n" f"🕒 {now.strftime('%d/%m/%Y %H:%M')}\n"
f"📊 Punti monitorati: {len(map_points_data)}" 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) photo_sent = send_telegram_photo(token, map_path, caption, debug_mode=DEBUG_MODE)
if DEBUG_MODE: if DEBUG_MODE:
print(f"Mappa inviata via Telegram: {photo_sent}") print(f"Mappa inviata via Telegram: {photo_sent}")
# Mantieni la mappa per la WebApp (non cancellare) # 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}")
print("Mappa inviata.") print("Mappa inviata.")
else: else:
if DEBUG_MODE: if DEBUG_MODE:
@@ -2313,7 +2274,7 @@ def main():
print("Nessuna variazione.") print("Nessuna variazione.")
if not DEBUG_MODE: if not DEBUG_MODE:
save_current_state(current_state, report_meta=report_meta, summary=ice_summary) save_current_state(current_state, report_meta=report_meta)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+9 -69
View File
@@ -52,12 +52,6 @@ TARGET_ZONES = {
"EMR-D1": "Pianura bolognese", "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, # Mappa codice zona regionale Arpae -> nome leggibile (deriva da TARGET_ZONES,
# togliendo il prefisso "EMR-": es. EMR-D1 -> D1 "Pianura bolognese"). # togliendo il prefisso "EMR-": es. EMR-D1 -> D1 "Pianura bolognese").
REGIONAL_TARGET_ZONES = {code.split("-")[-1]: name for code, name in TARGET_ZONES.items()} REGIONAL_TARGET_ZONES = {code.split("-")[-1]: name for code, name in TARGET_ZONES.items()}
@@ -182,15 +176,6 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
if message_html: if message_html:
message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE) message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE)
try:
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip civil_protection")
return False
token = load_bot_token() token = load_bot_token()
if not token: if not token:
LOGGER.warning("Token Telegram assente. Nessun invio effettuato.") LOGGER.warning("Token Telegram assente. Nessun invio effettuato.")
@@ -435,9 +420,7 @@ def format_message(parsed: dict) -> str:
lines.append(f"📅 <b>{html_lib.escape(day.get('date_label',''))}</b>") lines.append(f"📅 <b>{html_lib.escape(day.get('date_label',''))}</b>")
for zone in sorted(alerts.keys()): for zone in sorted(alerts.keys()):
note = ZONE_NOTES.get(zone) lines.append(f"📍 <b>{html_lib.escape(zone)}</b>")
zlabel = f"{zone} · {note}" if note else zone
lines.append(f"📍 <b>{html_lib.escape(zlabel)}</b>")
for entry in alerts[zone]: for entry in alerts[zone]:
lines.append(html_lib.escape(entry)) lines.append(html_lib.escape(entry))
lines.append("") lines.append("")
@@ -448,9 +431,7 @@ def format_message(parsed: dict) -> str:
lines.append(f"🗺️ <b>{html_lib.escape(titolo)}</b>") lines.append(f"🗺️ <b>{html_lib.escape(titolo)}</b>")
alerts = doc.get("alerts", {}) alerts = doc.get("alerts", {})
for zone in sorted(alerts.keys()): for zone in sorted(alerts.keys()):
note = ZONE_NOTES.get(zone) lines.append(f"📍 <b>{html_lib.escape(zone)}</b>")
zlabel = f"{zone} · {note}" if note else zone
lines.append(f"📍 <b>{html_lib.escape(zlabel)}</b>")
for entry in alerts[zone]: for entry in alerts[zone]:
lines.append(html_lib.escape(entry)) lines.append(html_lib.escape(entry))
lines.append("") lines.append("")
@@ -460,18 +441,6 @@ def format_message(parsed: dict) -> str:
lines.append("<i>Fonte: mappe.protezionecivile.gov.it</i>") lines.append("<i>Fonte: mappe.protezionecivile.gov.it</i>")
return "\n".join(lines) 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 # Main
# ============================================================================= # =============================================================================
@@ -526,50 +495,21 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False):
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato") LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato")
elif sig == last_sig: elif sig == last_sig:
LOGGER.info("Allerta già notificata e invariata. Nessuna nuova notifica.") 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 return
# A questo punto: ci sono allerte e sono nuove -> prova invio # A questo punto: ci sono allerte e sono nuove -> prova invio
msg = format_message(parsed) msg = format_message(parsed)
sent_ok = telegram_send_html(msg, chat_ids=chat_ids) sent_ok = telegram_send_html(msg, chat_ids=chat_ids)
web_ok = False
st = { if sent_ok:
LOGGER.info("Notifica allerta inviata con successo.")
save_state({
"date": today_str_italy(), "date": today_str_italy(),
"last_alert_signature": sig, "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: else:
LOGGER.warning("Invio non riuscito (Telegram/WebApp). Stato NON aggiornato.") # Non aggiorniamo lo stato: quando risolvi token/rete, reinvierà.
LOGGER.warning("Invio non riuscito (token mancante o errore Telegram). Stato NON aggiornato.")
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Civil protection alert") parser = argparse.ArgumentParser(description="Civil protection alert")
-4
View File
@@ -1,4 +0,0 @@
# 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
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.