Compare commits
8
Commits
4802b021fe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35677497dc | ||
|
|
aab8502e27 | ||
|
|
795a15a7b6 | ||
|
|
11823447a2 | ||
|
|
beb790ed7e | ||
|
|
3a7b867e83 | ||
|
|
7c2efce9df | ||
|
|
a9bbb92090 |
No files matched your search
@@ -1,17 +1,39 @@
|
||||
global_defs {
|
||||
enable_script_security
|
||||
script_user root
|
||||
}
|
||||
|
||||
vrrp_script chk_ha {
|
||||
script "/usr/local/sbin/keepalived-check-ha.sh"
|
||||
interval 5
|
||||
weight -30
|
||||
fall 3
|
||||
rise 2
|
||||
user root
|
||||
}
|
||||
|
||||
vrrp_instance VI_1 {
|
||||
state MASTER
|
||||
state BACKUP
|
||||
interface eth0
|
||||
virtual_router_id 51
|
||||
priority 101 # 101 = Priorità più alta (Master)
|
||||
advert_int 1
|
||||
priority 101
|
||||
advert_int 2
|
||||
preempt_delay 30
|
||||
unicast_src_ip 192.168.128.80
|
||||
unicast_peer {
|
||||
192.168.128.81
|
||||
}
|
||||
authentication {
|
||||
auth_type PASS
|
||||
auth_pass @Dedelove1
|
||||
}
|
||||
virtual_ipaddress {
|
||||
192.168.128.85 # Il nostro VIP
|
||||
192.168.128.85
|
||||
}
|
||||
notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a"
|
||||
notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a"
|
||||
notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault"
|
||||
track_script {
|
||||
chk_ha
|
||||
}
|
||||
notify_master "/usr/local/sbin/keepalived-notify-master.sh"
|
||||
notify_backup "/usr/local/sbin/keepalived-notify-backup.sh"
|
||||
notify_fault "/usr/local/sbin/keepalived-notify-fault.sh"
|
||||
}
|
||||
@@ -1,9 +1,28 @@
|
||||
global_defs {
|
||||
enable_script_security
|
||||
script_user root
|
||||
}
|
||||
|
||||
vrrp_script chk_ha {
|
||||
script "/usr/local/sbin/keepalived-check-ha.sh"
|
||||
interval 5
|
||||
weight -30
|
||||
fall 3
|
||||
rise 2
|
||||
user root
|
||||
}
|
||||
|
||||
vrrp_instance VI_1 {
|
||||
state BACKUP
|
||||
interface eth0
|
||||
virtual_router_id 51
|
||||
priority 100 # 100 = Priorità più bassa (Backup)
|
||||
advert_int 1
|
||||
priority 100
|
||||
advert_int 2
|
||||
preempt_delay 30
|
||||
unicast_src_ip 192.168.128.81
|
||||
unicast_peer {
|
||||
192.168.128.80
|
||||
}
|
||||
authentication {
|
||||
auth_type PASS
|
||||
auth_pass @Dedelove1
|
||||
@@ -11,7 +30,10 @@ vrrp_instance VI_1 {
|
||||
virtual_ipaddress {
|
||||
192.168.128.85
|
||||
}
|
||||
notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a"
|
||||
notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a"
|
||||
notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault"
|
||||
track_script {
|
||||
chk_ha
|
||||
}
|
||||
notify_master "/usr/local/sbin/keepalived-notify-master.sh"
|
||||
notify_backup "/usr/local/sbin/keepalived-notify-backup.sh"
|
||||
notify_fault "/usr/local/sbin/keepalived-notify-fault.sh"
|
||||
}
|
||||
@@ -3,3 +3,7 @@
|
||||
# DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
|
||||
# Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2)
|
||||
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
|
||||
# turni-app:live-latest
|
||||
# Dopo Watchtower: pull registry da rete/compose/failover anche senza container.
|
||||
# Override: FAILOVER_COMPOSE_DIR=...
|
||||
@@ -4,3 +4,7 @@
|
||||
# CHECK_PIP3=true
|
||||
# DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
|
||||
# Cron consigliato: 40 0 * * 6 (tra irrigazione serale ~19:30 e notturna ~02:30)
|
||||
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
|
||||
# irrigazione, turni-app:beta/alpha, meteo-alert, loogle-casa, ewelink_smart_home
|
||||
# Dopo Watchtower: pull registry da rete/compose/failover (Paperless, VW, Stalwart, …)
|
||||
# anche senza container. Override: FAILOVER_COMPOSE_DIR=...
|
||||
@@ -1,57 +1,57 @@
|
||||
#!/bin/bash
|
||||
# Clona NPM master (Pi1) → backup Pi2 + clone dormiente DS920 (NPM3).
|
||||
# Su DS920: extract solo se extrema-npm NON è running (evita SQLite corrotto in B1).
|
||||
# Synology: SFTP spesso chrootato → verso DS920 si usa stream tar via SSH (non scp).
|
||||
set -euo pipefail
|
||||
|
||||
# --- CONFIGURAZIONE ---
|
||||
REMOTE_IP="192.168.128.81"
|
||||
REMOTE_USER="daniely"
|
||||
# Cartella che contiene 'data' e 'letsencrypt'
|
||||
SOURCE_BASE="/home/daniely/docker/npm"
|
||||
TEMP_FILE="/tmp/npm_full_clone.tar.gz"
|
||||
REMOTE_IP="${REMOTE_IP:-192.168.128.81}"
|
||||
REMOTE_USER="${REMOTE_USER:-daniely}"
|
||||
DS920_IP="${DS920_IP:-192.168.128.100}"
|
||||
DS920_USER="${DS920_USER:-daniely}"
|
||||
DS920_NPM_ROOT="${DS920_NPM_ROOT:-/volume1/extrema/npm}"
|
||||
SOURCE_BASE="${SOURCE_BASE:-/home/daniely/docker/npm}"
|
||||
TEMP_FILE="${TEMP_FILE:-/tmp/npm_full_clone.tar.gz}"
|
||||
|
||||
echo "[$(date)] Avvio clonazione totale NPM (Master -> Backup)..."
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] sync-npm: $*"; }
|
||||
|
||||
log "Avvio clonazione NPM (Master → Pi2 + DS920)..."
|
||||
|
||||
# 1. Crea un archivio compresso di TUTTO (Database SQL + Certificati)
|
||||
# Usiamo sudo per leggere i file di root senza permessi negati
|
||||
# Escludiamo i log per risparmiare spazio
|
||||
sudo tar --exclude='*.log' -czf "$TEMP_FILE" -C "$SOURCE_BASE" data letsencrypt
|
||||
sudo chown "${USER}:${USER}" "$TEMP_FILE"
|
||||
|
||||
# 2. Assegna l'archivio all'utente corrente (per poterlo spedire via SCP)
|
||||
sudo chown $USER:$USER "$TEMP_FILE"
|
||||
|
||||
# 3. Spedisce l'archivio al Pi-2
|
||||
echo "Invio archivio al Pi-2 ($REMOTE_IP)..."
|
||||
scp -o ConnectTimeout=10 "$TEMP_FILE" "$REMOTE_USER@$REMOTE_IP:/tmp/"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Trasferimento riuscito. Applicazione sul Backup..."
|
||||
|
||||
# 4. Comanda al Pi-2 di:
|
||||
# a) Arrestare NPM (per sbloccare il database)
|
||||
# b) Scompattare sovrascrivendo tutto
|
||||
# c) Ripristinare i permessi di root
|
||||
# d) Riavviare NPM
|
||||
ssh "$REMOTE_USER@$REMOTE_IP" "
|
||||
echo ' ...Stop NPM...';
|
||||
docker stop npm;
|
||||
|
||||
echo ' ...Estrazione dati...';
|
||||
# Pulisce la cartella di destinazione prima di estrarre per evitare residui
|
||||
sudo rm -rf /home/daniely/docker/npm/data/*
|
||||
sudo rm -rf /home/daniely/docker/npm/letsencrypt/*
|
||||
sudo tar -xzf /tmp/npm_full_clone.tar.gz -C /home/daniely/docker/npm/;
|
||||
|
||||
echo ' ...Fix Permessi...';
|
||||
sudo chown -R root:root /home/daniely/docker/npm/;
|
||||
|
||||
echo ' ...Start NPM...';
|
||||
docker start npm;
|
||||
|
||||
# Pulizia remota
|
||||
rm /tmp/npm_full_clone.tar.gz
|
||||
"
|
||||
echo "[$(date)] Sincronizzazione Completata con Successo."
|
||||
# --- Pi-2 (hot backup: stop/extract/start) ---
|
||||
log "Invio archivio al Pi-2 ($REMOTE_IP)..."
|
||||
if scp -o ConnectTimeout=10 -o BatchMode=yes "$TEMP_FILE" "${REMOTE_USER}@${REMOTE_IP}:/tmp/npm_full_clone.tar.gz"; then
|
||||
ssh -o ConnectTimeout=15 -o BatchMode=yes "${REMOTE_USER}@${REMOTE_IP}" bash -s <<'EOS'
|
||||
set -e
|
||||
echo " ...Stop NPM..."
|
||||
docker stop npm 2>/dev/null || true
|
||||
echo " ...Estrazione dati..."
|
||||
sudo rm -rf /home/daniely/docker/npm/data/* /home/daniely/docker/npm/letsencrypt/*
|
||||
sudo tar -xzf /tmp/npm_full_clone.tar.gz -C /home/daniely/docker/npm/
|
||||
sudo chown -R root:root /home/daniely/docker/npm/
|
||||
echo " ...Start NPM..."
|
||||
docker start npm
|
||||
rm -f /tmp/npm_full_clone.tar.gz
|
||||
EOS
|
||||
log "Pi-2 sincronizzato OK"
|
||||
else
|
||||
echo "[$(date)] ERRORE CRITICO: Trasferimento fallito."
|
||||
log "ERRORE: trasferimento Pi-2 fallito"
|
||||
fi
|
||||
|
||||
# --- DS920 NPM3 (dormiente) ---
|
||||
log "Stream archivio al DS920 ($DS920_IP)..."
|
||||
if ssh -o ConnectTimeout=15 -o BatchMode=yes "${DS920_USER}@${DS920_IP}" \
|
||||
"export PATH=/usr/local/bin:\$PATH; docker ps --format '{{.Names}}' 2>/dev/null | grep -qx extrema-npm"; then
|
||||
log "SKIP DS920: extrema-npm running (B1 armato) — sync al prossimo disarm"
|
||||
else
|
||||
if cat "$TEMP_FILE" | ssh -o ConnectTimeout=180 -o BatchMode=yes "${DS920_USER}@${DS920_IP}" \
|
||||
"export PATH=/usr/local/bin:\$PATH; NPM_ROOT=$(printf %q "$DS920_NPM_ROOT"); mkdir -p \"\$NPM_ROOT/data\" \"\$NPM_ROOT/letsencrypt\"; find \"\$NPM_ROOT/data\" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true; find \"\$NPM_ROOT/letsencrypt\" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true; tar -xzf - -C \"\$NPM_ROOT\"; chmod -R a+rX \"\$NPM_ROOT\" 2>/dev/null || true; echo NPM3_EXTRACT_OK; ls \"\$NPM_ROOT/data/nginx/proxy_host\" 2>/dev/null | head -5"; then
|
||||
log "DS920 NPM3 sincronizzato OK (dormiente)"
|
||||
else
|
||||
log "ERRORE: extract DS920 fallito"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 5. Pulizia locale
|
||||
sudo rm -f "$TEMP_FILE"
|
||||
log "Sincronizzazione NPM completata."
|
||||
@@ -1,71 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ================================================
|
||||
# SENTINELLA DI BACKUP (Gira su Pi-1 Master)
|
||||
# Controlla Pi-2 e attiva failover bundle IoT su Pi-1 se down.
|
||||
# ================================================
|
||||
|
||||
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
||||
HA_FAILOVER="/home/daniely/rete/scripts/ha-failover.sh"
|
||||
|
||||
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||
echo "Errore: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_IP="192.168.128.81"
|
||||
TARGET_NAME="🍓 Pi-2 (Backup & Monitor)"
|
||||
STATE_FILE="/mnt/ha-apps/.metadata/pi2-watchdog.state"
|
||||
SIM_STATE="/mnt/ha-apps/.metadata/failover-sim.state"
|
||||
LEGACY_STATE_FILE="/tmp/pi2_watchdog.state"
|
||||
SSH_OPTS=(-o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
|
||||
|
||||
if [[ -f "$SIM_STATE" ]] && [[ "$(cat "$SIM_STATE" 2>/dev/null)" != "none" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
send_alert() {
|
||||
local title="$1"
|
||||
local body="$2"
|
||||
local severity="$3"
|
||||
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category watchdog_pi2 \
|
||||
--severity "$severity" &
|
||||
}
|
||||
|
||||
pi2_reachable() {
|
||||
ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1 && return 0
|
||||
ssh "${SSH_OPTS[@]}" pi2 "exit 0" 2>/dev/null
|
||||
}
|
||||
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
if [ -f "$LEGACY_STATE_FILE" ]; then
|
||||
cp "$LEGACY_STATE_FILE" "$STATE_FILE"
|
||||
else
|
||||
echo "UP" > "$STATE_FILE"
|
||||
fi
|
||||
fi
|
||||
LAST_STATE=$(cat "$STATE_FILE")
|
||||
|
||||
if pi2_reachable; then
|
||||
if [ "$LAST_STATE" == "DOWN" ]; then
|
||||
send_alert \
|
||||
"Pi-2 online" \
|
||||
"RISOLTO: $TARGET_NAME è tornato ONLINE! Failback bundle IoT in corso." \
|
||||
"info"
|
||||
echo "UP" > "$STATE_FILE"
|
||||
if [[ -x "$HA_FAILOVER" ]]; then
|
||||
sudo bash -c "\"$HA_FAILOVER\" stop-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if [ "$LAST_STATE" == "UP" ]; then
|
||||
send_alert \
|
||||
"Pi-2 offline" \
|
||||
"ALLARME: $TARGET_NAME è OFFLINE! Avvio failover bundle IoT su Pi-1." \
|
||||
"warning"
|
||||
echo "DOWN" > "$STATE_FILE"
|
||||
if [[ -x "$HA_FAILOVER" ]]; then
|
||||
sudo bash -c "\"$HA_FAILOVER\" start-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper legacy — redirige al modulo infra-monitor
|
||||
exec /home/daniely/rete/infra-monitor/pi2-watchdog.sh "$@"
|
||||
@@ -20,6 +20,8 @@ PIHOLE_BIN="/usr/local/bin/pihole"
|
||||
REBOOT_DELAY_MIN=2
|
||||
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
|
||||
WATCHTOWER_TIMEOUT=900
|
||||
# Compose HA usati al pivot: Watchtower aggiorna solo i container esistenti.
|
||||
FAILOVER_COMPOSE_DIR="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
|
||||
|
||||
HOST_LABEL="$(hostname -s)"
|
||||
REBOOT_ON_SUCCESS=true
|
||||
@@ -45,12 +47,21 @@ done
|
||||
case "$(hostname -s)" in
|
||||
pi1)
|
||||
HOST_LABEL="Pi-1 (Master)"
|
||||
# Immagini build locali (no registry pubblico) → escluse da Watchtower
|
||||
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||
;;
|
||||
pi2)
|
||||
HOST_LABEL="Pi-2 (Backup)"
|
||||
CHECK_PIP3=true
|
||||
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
|
||||
# Immagini build locali (no registry pubblico) → escluse da Watchtower
|
||||
DOCKER_IGNORE_IMAGES=(
|
||||
"irrigazione:latest"
|
||||
"turni-app:beta-latest"
|
||||
"turni-app:alpha-latest"
|
||||
"meteo-alert:latest"
|
||||
"loogle-casa:latest"
|
||||
"ewelink_smart_home:1.4.6"
|
||||
)
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -143,34 +154,52 @@ image_is_ignored() {
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_watchtower_labels() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
local name image
|
||||
# Elenco nomi container da escludere (immagini locali / no registry).
|
||||
# Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
|
||||
watchtower_disabled_containers() {
|
||||
local name image disabled=()
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
name=${line%%|*}
|
||||
image=${line#*|}
|
||||
image_is_ignored "$image" || continue
|
||||
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
|
||||
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
if image_is_ignored "$image"; then
|
||||
disabled+=("$name")
|
||||
fi
|
||||
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
if ((${#disabled[@]} > 0)); then
|
||||
local IFS=,
|
||||
printf '%s' "${disabled[*]}"
|
||||
fi
|
||||
}
|
||||
|
||||
run_watchtower() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
|
||||
ensure_watchtower_labels
|
||||
local disable_list
|
||||
disable_list=$(watchtower_disabled_containers)
|
||||
|
||||
log "▶ Watchtower (run-once, container aggiornabili da registry)"
|
||||
append_report ""
|
||||
append_report "=== Watchtower run-once ==="
|
||||
if [[ -n "$disable_list" ]]; then
|
||||
log "Watchtower: esclusi container locali: $disable_list"
|
||||
append_report "Esclusi (immagini locali): $disable_list"
|
||||
fi
|
||||
|
||||
local -a env_args=(
|
||||
-e WATCHTOWER_CLEANUP=true
|
||||
-e WATCHTOWER_ROLLING_RESTART=false
|
||||
-e "WATCHTOWER_TIMEOUT=${WATCHTOWER_TIMEOUT}"
|
||||
-e TZ=Europe/Rome
|
||||
)
|
||||
if [[ -n "$disable_list" ]]; then
|
||||
env_args+=(-e "WATCHTOWER_DISABLE_CONTAINERS=${disable_list}")
|
||||
fi
|
||||
|
||||
local output rc=0
|
||||
output=$(docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e WATCHTOWER_CLEANUP=true \
|
||||
-e WATCHTOWER_ROLLING_RESTART=false \
|
||||
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
|
||||
-e TZ=Europe/Rome \
|
||||
"${env_args[@]}" \
|
||||
"$WATCHTOWER_IMAGE" \
|
||||
--run-once 2>&1) || rc=$?
|
||||
|
||||
@@ -191,6 +220,99 @@ run_watchtower() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Espande ${VAR:-default} (usato nei compose Turni). Altri ${VAR} → scarta.
|
||||
expand_compose_image() {
|
||||
local raw="$1"
|
||||
while [[ "$raw" =~ \$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\} ]]; do
|
||||
raw="${raw/${BASH_REMATCH[0]}/${BASH_REMATCH[2]}}"
|
||||
done
|
||||
[[ "$raw" == *'$'* ]] && return 1
|
||||
printf '%s' "$raw"
|
||||
}
|
||||
|
||||
# Build locali / no registry: non fare docker pull (fallirebbe o scaricherebbe un omonimo Hub).
|
||||
failover_image_is_local_build() {
|
||||
local image="$1"
|
||||
local name="${image%%:*}"
|
||||
image_is_ignored "$image" && return 0
|
||||
case "$name" in
|
||||
nodus-backend|nodus-frontend|loogle-casa|loogle-mcp|irrigazione|ewelink_smart_home|turni-app)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
collect_failover_registry_images() {
|
||||
local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
|
||||
local f line img expanded
|
||||
[[ -d "$dir" ]] || return 1
|
||||
while IFS= read -r f; do
|
||||
[[ -f "$f" ]] || continue
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
img=$(expand_compose_image "$line") || continue
|
||||
failover_image_is_local_build "$img" && continue
|
||||
printf '%s\n' "$img"
|
||||
done < <(sed -n 's/^[[:space:]]*image:[[:space:]]*//p' "$f" | sed 's/["'\'']//g')
|
||||
done < <(find "$dir" -type f \( -name '*.yml' -o -name '*.yaml' \) | sort)
|
||||
}
|
||||
|
||||
pull_failover_standby_images() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
|
||||
local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
|
||||
log "▶ Pull immagini failover (registry, anche senza container)"
|
||||
append_report ""
|
||||
append_report "=== Pull immagini failover (standby) ==="
|
||||
append_report "Compose: $dir"
|
||||
|
||||
if [[ ! -d "$dir" ]]; then
|
||||
WARNINGS+=("Directory compose failover assente: $dir")
|
||||
append_report "Directory assente"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local -a images=()
|
||||
local img
|
||||
while IFS= read -r img; do
|
||||
[[ -z "$img" ]] && continue
|
||||
images+=("$img")
|
||||
done < <(collect_failover_registry_images | sort -u)
|
||||
|
||||
if ((${#images[@]} == 0)); then
|
||||
WARNINGS+=("Nessuna immagine registry nei compose failover")
|
||||
append_report "Lista vuota"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pulled=0 failed=0 skipped=0
|
||||
local before after
|
||||
for img in "${images[@]}"; do
|
||||
before=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true)
|
||||
if docker pull "$img" >> "$REPORT_FILE" 2>&1; then
|
||||
after=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true)
|
||||
if [[ -n "$before" && "$before" == "$after" ]]; then
|
||||
skipped=$((skipped + 1))
|
||||
log " = $img (già aggiornata)"
|
||||
append_report "unchanged: $img"
|
||||
else
|
||||
pulled=$((pulled + 1))
|
||||
log " ↑ $img"
|
||||
append_report "updated: $img"
|
||||
fi
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
WARNINGS+=("docker pull fallito: $img")
|
||||
log " ✗ $img"
|
||||
fi
|
||||
done
|
||||
|
||||
NOTES+=("Failover images: ${#images[@]} registry, $pulled aggiornate, $skipped già ok, $failed errori")
|
||||
append_report "Riepilogo: ${#images[@]} immagini, aggiornate=$pulled, invariate=$skipped, errori=$failed"
|
||||
log "✓ Pull failover: aggiornate=$pulled invariate=$skipped errori=$failed"
|
||||
}
|
||||
|
||||
audit_apt() {
|
||||
local holds
|
||||
holds=$(apt-mark showhold 2>/dev/null || true)
|
||||
@@ -413,8 +535,10 @@ fi
|
||||
# 3. EEPROM firmware
|
||||
audit_eeprom
|
||||
|
||||
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
|
||||
# 4. Aggiornamento container Docker (Watchtower run-once; il daemon è MONITOR_ONLY)
|
||||
run_watchtower
|
||||
# 4b. Immagini registry dei compose failover (Paperless, Vaultwarden, …) anche se non c'è container
|
||||
pull_failover_standby_images
|
||||
|
||||
# 5. Audit residui
|
||||
audit_docker
|
||||
|
||||
@@ -1,36 +1,47 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==============================================================================
|
||||
# 💾 AUTO GIT BACKUP - DINAMICO
|
||||
# AUTO GIT BACKUP - DINAMICO
|
||||
# Sincronizza automaticamente tutti gli script .sh e .py dal Pi2 e dal Pi1
|
||||
# verso il repository Gitea locale, poi esegue il push.
|
||||
# ==============================================================================
|
||||
|
||||
set -u
|
||||
|
||||
# --- CONFIGURAZIONE ---
|
||||
REPO_DIR="/home/daniely/loogle-repo"
|
||||
LOG_FILE="/var/log/git-backup.log"
|
||||
DATE=$(date +"%Y-%m-%d %H:%M")
|
||||
export GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=10"
|
||||
|
||||
# IP del Pi1 (Master) da cui prelevare i file remoti
|
||||
PI1_IP="192.168.128.80"
|
||||
PI1_USER="daniely"
|
||||
|
||||
# Redirige tutto l'output (stdout e stderr) nel log
|
||||
exec >> $LOG_FILE 2>&1
|
||||
exec >> "$LOG_FILE" 2>&1
|
||||
|
||||
echo "=== Inizio Backup Git: $DATE ==="
|
||||
|
||||
fail() {
|
||||
echo "❌ $1"
|
||||
echo "=== Fine Backup Git (ERRORE) ==="
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 1. PREPARAZIONE REPOSITORY
|
||||
# --------------------------
|
||||
if [ -d "$REPO_DIR" ]; then
|
||||
cd "$REPO_DIR" || { echo "❌ Errore: Impossibile entrare in $REPO_DIR"; exit 1; }
|
||||
cd "$REPO_DIR" || fail "Impossibile entrare in $REPO_DIR"
|
||||
else
|
||||
echo "❌ Errore: La cartella $REPO_DIR non esiste."; exit 1;
|
||||
fail "La cartella $REPO_DIR non esiste."
|
||||
fi
|
||||
|
||||
# Aggiorna il repository locale (pull) per evitare conflitti
|
||||
echo "🔄 Eseguo Git Pull..."
|
||||
git pull origin main
|
||||
if ! git pull --ff-only origin main; then
|
||||
echo "⚠️ Git pull fallito (proseguo con commit locale se possibile)."
|
||||
fi
|
||||
|
||||
# Assicuriamoci che le cartelle di destinazione esistano
|
||||
mkdir -p ./scripts/pi2-backup
|
||||
@@ -43,7 +54,6 @@ mkdir -p ./configs
|
||||
echo "📂 Raccolta dinamica file locali (Pi-2)..."
|
||||
|
||||
# A. Script nella Home (solo .sh) -> scripts/pi2-backup
|
||||
# --include='*.sh' prende gli script, --exclude='*' ignora tutto il resto
|
||||
rsync -av --include='*.sh' --exclude='*' /home/daniely/ "$REPO_DIR/scripts/pi2-backup/"
|
||||
|
||||
# B. Script del Bot (tutti .py e .sh) -> services/telegram-bot
|
||||
@@ -61,32 +71,42 @@ fi
|
||||
echo "📡 Raccolta dinamica file remoti (Pi-1)..."
|
||||
|
||||
# A. Script nella Home remota (.sh e .py) -> scripts/pi1-master
|
||||
# Nota: Richiede che le chiavi SSH siano configurate per non chiedere password
|
||||
rsync -av -e "ssh -q" --include='*.sh' --include='*.py' --exclude='*' $PI1_USER@$PI1_IP:/home/daniely/ "$REPO_DIR/scripts/pi1-master/"
|
||||
rsync -av -e "ssh -q" --include='*.sh' --include='*.py' --exclude='*' \
|
||||
"$PI1_USER@$PI1_IP:/home/daniely/" "$REPO_DIR/scripts/pi1-master/" \
|
||||
|| echo "⚠️ rsync Pi1 fallito (ignorato)"
|
||||
|
||||
# B. Recupero file specifici fuori dalla home (Legacy da vecchio script)
|
||||
# Se dhcp-alert.sh esiste ancora in /usr/local/bin, lo prendiamo
|
||||
scp -q $PI1_USER@$PI1_IP:/usr/local/bin/dhcp-alert.sh ./scripts/pi1-master/ 2>/dev/null || echo "⚠️ dhcp-alert.sh non trovato su Pi1 (ignorato)"
|
||||
scp -q "$PI1_USER@$PI1_IP:/usr/local/bin/dhcp-alert.sh" ./scripts/pi1-master/ 2>/dev/null \
|
||||
|| echo "⚠️ dhcp-alert.sh non trovato su Pi1 (ignorato)"
|
||||
|
||||
# C. Configurazione Keepalived Remota
|
||||
scp -q $PI1_USER@$PI1_IP:/etc/keepalived/keepalived.conf ./configs/keepalived_pi1.conf 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
if scp -q "$PI1_USER@$PI1_IP:/etc/keepalived/keepalived.conf" ./configs/keepalived_pi1.conf 2>/dev/null; then
|
||||
echo "✅ Configurazione Keepalived Pi1 scaricata."
|
||||
else
|
||||
echo "⚠️ Impossibile scaricare Keepalived conf da Pi1."
|
||||
fi
|
||||
|
||||
# D. Sorgente Loogle MCP (app completa: qui vive solo su Pi1, fuori da git)
|
||||
# .env resta escluso: contiene JWT secret e token Paperless/Gitea/OpenAI.
|
||||
mkdir -p "$REPO_DIR/services/loogle-mcp"
|
||||
rsync -av --delete -e "ssh -q" \
|
||||
--exclude='.env' --exclude='data/' --exclude='__pycache__/' --exclude='*.pyc' --exclude='.cursor/' \
|
||||
"$PI1_USER@$PI1_IP:/home/daniely/docker/loogle-mcp/" "$REPO_DIR/services/loogle-mcp/" \
|
||||
|| echo "⚠️ rsync sorgente MCP fallito (ignorato)"
|
||||
|
||||
# 4. GIT PUSH
|
||||
# -----------
|
||||
# Verifica se ci sono cambiamenti reali
|
||||
if [[ `git status --porcelain` ]]; then
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "📝 Rilevati cambiamenti. Eseguo Commit e Push..."
|
||||
|
||||
git add .
|
||||
git commit -m "Backup automatico script del $DATE"
|
||||
git push -u origin main
|
||||
git commit -m "Backup automatico script del $DATE" || fail "Commit fallito"
|
||||
|
||||
if git push origin main; then
|
||||
echo "✅ Push completato con successo."
|
||||
else
|
||||
fail "Push su Gitea fallito (SSH/auth/Gitea down?)."
|
||||
fi
|
||||
else
|
||||
echo "ℹ️ Nessun cambiamento rilevato. Repository già aggiornato."
|
||||
fi
|
||||
|
||||
@@ -1,142 +1,2 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ================================================
|
||||
# 🔍 SUPER WATCHDOG DI RETE (Gira su Pi-2)
|
||||
# ================================================
|
||||
|
||||
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
||||
|
||||
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||
echo "ERRORE: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STATE_DIR="/tmp/watchdog_states"
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# Riavvio giornaliero AP WiFi: silenzio allarmi per REBOOT_GRACE_MIN minuti
|
||||
# dall'orario programmato. Se ancora DOWN dopo la finestra → allarme.
|
||||
REBOOT_GRACE_MIN=5
|
||||
|
||||
TARGETS=(
|
||||
"🍓 Pi-1 (Master)|192.168.128.80"
|
||||
"🗄️ NAS DS920+|192.168.128.100"
|
||||
"🌍 Internet (Google)|8.8.8.8"
|
||||
"📡 Router Main|192.168.128.1"
|
||||
"🗄️ NAS DS214|192.168.128.90"
|
||||
"🔌 Switch Sala (.105)|192.168.128.105"
|
||||
"🔌 Switch Taverna (.106)|192.168.128.106"
|
||||
"🔌 Switch Lavanderia (.107)|192.168.128.107"
|
||||
"📶 WiFi Sala (.101)|192.168.128.101"
|
||||
"📶 WiFi Luca (.102)|192.168.128.102"
|
||||
"📶 WiFi Taverna (.103)|192.168.128.103"
|
||||
"📶 WiFi Dado (.104)|192.168.128.104"
|
||||
"📶 WiFi Esterno (.108)|192.168.128.108"
|
||||
"📶 WiFi Pozzo (.109)|192.168.128.109"
|
||||
"📷 Cam Matrimoniale|192.168.135.2"
|
||||
"📷 Cam Luca|192.168.135.3"
|
||||
"📷 Cam Ingresso|192.168.135.4"
|
||||
"📷 Cam Sala|192.168.135.5"
|
||||
"📷 Cam Taverna|192.168.135.6"
|
||||
"📷 Cam Retro|192.168.135.7"
|
||||
)
|
||||
|
||||
send_alert() {
|
||||
local title="$1"
|
||||
local body="$2"
|
||||
local severity="$3"
|
||||
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category super_watchdog \
|
||||
--severity "$severity" &
|
||||
}
|
||||
|
||||
# Restituisce i minuti da mezzanotte dell'inizio riavvio (o vuoto se non programmato).
|
||||
# 04:00 → Dado (.104), Sala (.101)
|
||||
# 04:30 → Luca (.102), Taverna (.103)
|
||||
# 14:00 → Esterno (.108), Pozzo (.109)
|
||||
wifi_reboot_start_min() {
|
||||
case "$1" in
|
||||
192.168.128.101|192.168.128.104) echo 240 ;; # 04:00
|
||||
192.168.128.102|192.168.128.103) echo 270 ;; # 04:30
|
||||
192.168.128.108|192.168.128.109) echo 840 ;; # 14:00
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 0 se siamo nella finestra [start, start+grace) del riavvio programmato.
|
||||
in_wifi_reboot_window() {
|
||||
local start
|
||||
start=$(wifi_reboot_start_min "$1")
|
||||
[[ -n "$start" ]] || return 1
|
||||
local now_min=$((10#$(date +%H) * 60 + 10#$(date +%M)))
|
||||
local end=$((start + REBOOT_GRACE_MIN))
|
||||
[[ $now_min -ge $start && $now_min -lt $end ]]
|
||||
}
|
||||
|
||||
echo "--- Inizio controllo $(date) ---"
|
||||
|
||||
for target_line in "${TARGETS[@]}"; do
|
||||
NAME=$(echo "$target_line" | cut -d'|' -f1)
|
||||
IP=$(echo "$target_line" | cut -d'|' -f2)
|
||||
|
||||
SAFE_IP="${IP//./_}"
|
||||
STATE_FILE="$STATE_DIR/${SAFE_IP}.state"
|
||||
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
LAST_STATE=$(cat "$STATE_FILE")
|
||||
else
|
||||
LAST_STATE="UP"
|
||||
echo "UP" > "$STATE_FILE"
|
||||
fi
|
||||
|
||||
ping -c 2 -W 1 "$IP" > /dev/null 2>&1
|
||||
PING_RESULT=$?
|
||||
|
||||
if [ $PING_RESULT -eq 0 ]; then
|
||||
if [ "$LAST_STATE" == "DOWN" ]; then
|
||||
send_alert \
|
||||
"Dispositivo online" \
|
||||
"RISOLTO: $NAME è tornato ONLINE! IP: $IP" \
|
||||
"info"
|
||||
echo "UP" > "$STATE_FILE"
|
||||
echo "--> $NAME tornato UP. Notifica inviata."
|
||||
elif [ "$LAST_STATE" == "REBOOT_DOWN" ]; then
|
||||
# Ripristino dopo riavvio programmato: nessun allarme off/on
|
||||
echo "UP" > "$STATE_FILE"
|
||||
echo "--> $NAME tornato UP dopo riavvio programmato. Nessuna notifica."
|
||||
fi
|
||||
else
|
||||
if [ "$LAST_STATE" == "UP" ]; then
|
||||
if in_wifi_reboot_window "$IP"; then
|
||||
echo "REBOOT_DOWN" > "$STATE_FILE"
|
||||
echo "--> $NAME DOWN in finestra riavvio programmato. Silenzio."
|
||||
else
|
||||
ICON="⚠️"
|
||||
if [[ "$NAME" == *"🚨"* || "$NAME" == *"🌍"* ]]; then ICON="🚨 CRITICO:"; fi
|
||||
|
||||
send_alert \
|
||||
"Dispositivo offline" \
|
||||
"$ICON ALLARME: $NAME è OFFLINE! IP: $IP non risponde." \
|
||||
"warning"
|
||||
echo "DOWN" > "$STATE_FILE"
|
||||
echo "--> $NAME andato DOWN. Notifica inviata."
|
||||
fi
|
||||
elif [ "$LAST_STATE" == "REBOOT_DOWN" ]; then
|
||||
if in_wifi_reboot_window "$IP"; then
|
||||
echo "$NAME ancora DOWN in finestra riavvio. Silenzio."
|
||||
else
|
||||
# Oltre i 5 minuti dal riavvio programmato: allarme reale
|
||||
ICON="⚠️"
|
||||
send_alert \
|
||||
"Dispositivo offline" \
|
||||
"$ICON ALLARME: $NAME è OFFLINE oltre il riavvio programmato! IP: $IP non risponde." \
|
||||
"warning"
|
||||
echo "DOWN" > "$STATE_FILE"
|
||||
echo "--> $NAME ancora DOWN dopo finestra riavvio. Notifica inviata."
|
||||
fi
|
||||
else
|
||||
echo "$NAME ancora DOWN. Nessuna notifica."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "--- Fine controllo ---"
|
||||
#!/usr/bin/env bash
|
||||
exec /home/daniely/rete/infra-monitor/perimeter-watch.sh "$@"
|
||||
@@ -20,6 +20,8 @@ PIHOLE_BIN="/usr/local/bin/pihole"
|
||||
REBOOT_DELAY_MIN=2
|
||||
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
|
||||
WATCHTOWER_TIMEOUT=900
|
||||
# Compose HA usati al pivot: Watchtower aggiorna solo i container esistenti.
|
||||
FAILOVER_COMPOSE_DIR="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
|
||||
|
||||
HOST_LABEL="$(hostname -s)"
|
||||
REBOOT_ON_SUCCESS=true
|
||||
@@ -45,12 +47,21 @@ done
|
||||
case "$(hostname -s)" in
|
||||
pi1)
|
||||
HOST_LABEL="Pi-1 (Master)"
|
||||
# Immagini build locali (no registry pubblico) → escluse da Watchtower
|
||||
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||
;;
|
||||
pi2)
|
||||
HOST_LABEL="Pi-2 (Backup)"
|
||||
CHECK_PIP3=true
|
||||
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
|
||||
# Immagini build locali (no registry pubblico) → escluse da Watchtower
|
||||
DOCKER_IGNORE_IMAGES=(
|
||||
"irrigazione:latest"
|
||||
"turni-app:beta-latest"
|
||||
"turni-app:alpha-latest"
|
||||
"meteo-alert:latest"
|
||||
"loogle-casa:latest"
|
||||
"ewelink_smart_home:1.4.6"
|
||||
)
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -143,34 +154,52 @@ image_is_ignored() {
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_watchtower_labels() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
local name image
|
||||
# Elenco nomi container da escludere (immagini locali / no registry).
|
||||
# Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
|
||||
watchtower_disabled_containers() {
|
||||
local name image disabled=()
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
name=${line%%|*}
|
||||
image=${line#*|}
|
||||
image_is_ignored "$image" || continue
|
||||
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
|
||||
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
if image_is_ignored "$image"; then
|
||||
disabled+=("$name")
|
||||
fi
|
||||
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
if ((${#disabled[@]} > 0)); then
|
||||
local IFS=,
|
||||
printf '%s' "${disabled[*]}"
|
||||
fi
|
||||
}
|
||||
|
||||
run_watchtower() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
|
||||
ensure_watchtower_labels
|
||||
local disable_list
|
||||
disable_list=$(watchtower_disabled_containers)
|
||||
|
||||
log "▶ Watchtower (run-once, container aggiornabili da registry)"
|
||||
append_report ""
|
||||
append_report "=== Watchtower run-once ==="
|
||||
if [[ -n "$disable_list" ]]; then
|
||||
log "Watchtower: esclusi container locali: $disable_list"
|
||||
append_report "Esclusi (immagini locali): $disable_list"
|
||||
fi
|
||||
|
||||
local -a env_args=(
|
||||
-e WATCHTOWER_CLEANUP=true
|
||||
-e WATCHTOWER_ROLLING_RESTART=false
|
||||
-e "WATCHTOWER_TIMEOUT=${WATCHTOWER_TIMEOUT}"
|
||||
-e TZ=Europe/Rome
|
||||
)
|
||||
if [[ -n "$disable_list" ]]; then
|
||||
env_args+=(-e "WATCHTOWER_DISABLE_CONTAINERS=${disable_list}")
|
||||
fi
|
||||
|
||||
local output rc=0
|
||||
output=$(docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e WATCHTOWER_CLEANUP=true \
|
||||
-e WATCHTOWER_ROLLING_RESTART=false \
|
||||
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
|
||||
-e TZ=Europe/Rome \
|
||||
"${env_args[@]}" \
|
||||
"$WATCHTOWER_IMAGE" \
|
||||
--run-once 2>&1) || rc=$?
|
||||
|
||||
@@ -191,6 +220,99 @@ run_watchtower() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Espande ${VAR:-default} (usato nei compose Turni). Altri ${VAR} → scarta.
|
||||
expand_compose_image() {
|
||||
local raw="$1"
|
||||
while [[ "$raw" =~ \$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\} ]]; do
|
||||
raw="${raw/${BASH_REMATCH[0]}/${BASH_REMATCH[2]}}"
|
||||
done
|
||||
[[ "$raw" == *'$'* ]] && return 1
|
||||
printf '%s' "$raw"
|
||||
}
|
||||
|
||||
# Build locali / no registry: non fare docker pull (fallirebbe o scaricherebbe un omonimo Hub).
|
||||
failover_image_is_local_build() {
|
||||
local image="$1"
|
||||
local name="${image%%:*}"
|
||||
image_is_ignored "$image" && return 0
|
||||
case "$name" in
|
||||
nodus-backend|nodus-frontend|loogle-casa|loogle-mcp|irrigazione|ewelink_smart_home|turni-app)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
collect_failover_registry_images() {
|
||||
local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
|
||||
local f line img expanded
|
||||
[[ -d "$dir" ]] || return 1
|
||||
while IFS= read -r f; do
|
||||
[[ -f "$f" ]] || continue
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
img=$(expand_compose_image "$line") || continue
|
||||
failover_image_is_local_build "$img" && continue
|
||||
printf '%s\n' "$img"
|
||||
done < <(sed -n 's/^[[:space:]]*image:[[:space:]]*//p' "$f" | sed 's/["'\'']//g')
|
||||
done < <(find "$dir" -type f \( -name '*.yml' -o -name '*.yaml' \) | sort)
|
||||
}
|
||||
|
||||
pull_failover_standby_images() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
|
||||
local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
|
||||
log "▶ Pull immagini failover (registry, anche senza container)"
|
||||
append_report ""
|
||||
append_report "=== Pull immagini failover (standby) ==="
|
||||
append_report "Compose: $dir"
|
||||
|
||||
if [[ ! -d "$dir" ]]; then
|
||||
WARNINGS+=("Directory compose failover assente: $dir")
|
||||
append_report "Directory assente"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local -a images=()
|
||||
local img
|
||||
while IFS= read -r img; do
|
||||
[[ -z "$img" ]] && continue
|
||||
images+=("$img")
|
||||
done < <(collect_failover_registry_images | sort -u)
|
||||
|
||||
if ((${#images[@]} == 0)); then
|
||||
WARNINGS+=("Nessuna immagine registry nei compose failover")
|
||||
append_report "Lista vuota"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pulled=0 failed=0 skipped=0
|
||||
local before after
|
||||
for img in "${images[@]}"; do
|
||||
before=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true)
|
||||
if docker pull "$img" >> "$REPORT_FILE" 2>&1; then
|
||||
after=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true)
|
||||
if [[ -n "$before" && "$before" == "$after" ]]; then
|
||||
skipped=$((skipped + 1))
|
||||
log " = $img (già aggiornata)"
|
||||
append_report "unchanged: $img"
|
||||
else
|
||||
pulled=$((pulled + 1))
|
||||
log " ↑ $img"
|
||||
append_report "updated: $img"
|
||||
fi
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
WARNINGS+=("docker pull fallito: $img")
|
||||
log " ✗ $img"
|
||||
fi
|
||||
done
|
||||
|
||||
NOTES+=("Failover images: ${#images[@]} registry, $pulled aggiornate, $skipped già ok, $failed errori")
|
||||
append_report "Riepilogo: ${#images[@]} immagini, aggiornate=$pulled, invariate=$skipped, errori=$failed"
|
||||
log "✓ Pull failover: aggiornate=$pulled invariate=$skipped errori=$failed"
|
||||
}
|
||||
|
||||
audit_apt() {
|
||||
local holds
|
||||
holds=$(apt-mark showhold 2>/dev/null || true)
|
||||
@@ -415,6 +537,8 @@ audit_eeprom
|
||||
|
||||
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
|
||||
run_watchtower
|
||||
# 4b. Immagini registry dei compose failover (Paperless, Vaultwarden, …) anche se non c'è container
|
||||
pull_failover_standby_images
|
||||
|
||||
# 5. Audit residui
|
||||
audit_docker
|
||||
|
||||
@@ -1,16 +1,50 @@
|
||||
#!/bin/bash
|
||||
# raspiBackup post extension — notifica Loogle Casa (solo WebApp).
|
||||
#
|
||||
# RaspiBackup chiama il post hook PRIMA di STARTSERVICES: su Pi2 Casa è giù
|
||||
# (before-stop ferma loogle-casa, STOPSERVICES ferma docker). Non notificare
|
||||
# subito: systemd-run stacca dal cgroup e riprova finché l'API risponde.
|
||||
#
|
||||
# Sourced da raspiBackup: $1 = return code backup.
|
||||
# Eseguito da systemd-run: $1 = --wait-send
|
||||
|
||||
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
||||
MAX_TRIES="${LOOGLE_BACKUP_NOTIFY_TRIES:-36}"
|
||||
SLEEP_SECS="${LOOGLE_BACKUP_NOTIFY_SLEEP:-5}"
|
||||
|
||||
wait_send() {
|
||||
local title="$1" body="$2" severity="$3" tag="$4"
|
||||
local i
|
||||
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||
echo "loogle-notify.sh assente" >&2
|
||||
exit 0
|
||||
fi
|
||||
for ((i = 1; i <= MAX_TRIES; i++)); do
|
||||
if "$LOOGLE_NOTIFY" --title "$title" --body "$body" --category raspi_backup \
|
||||
--severity "$severity" --tag "$tag"; then
|
||||
echo "notify ok try=$i tag=$tag"
|
||||
exit 0
|
||||
fi
|
||||
echo "notify retry $i/$MAX_TRIES tag=$tag" >&2
|
||||
sleep "$SLEEP_SECS"
|
||||
done
|
||||
echo "notify failed after ${MAX_TRIES} tries tag=$tag" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--wait-send" ]]; then
|
||||
wait_send "${2:-}" "${3:-}" "${4:-info}" "${5:-raspi_backup}"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
RC="${1:-1}"
|
||||
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
||||
HOST="${HOSTNAME:-$(hostname)}"
|
||||
HOST="${HOST%%.*}"
|
||||
|
||||
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||
return 0 2>/dev/null || exit 0
|
||||
fi
|
||||
|
||||
HOST="${HOSTNAME:-$(hostname)}"
|
||||
|
||||
if (( RC == 0 )); then
|
||||
TITLE="Backup completato"
|
||||
BODY="Backup di ${HOST} terminato con successo."
|
||||
@@ -21,7 +55,19 @@ else
|
||||
SEVERITY="error"
|
||||
fi
|
||||
|
||||
"$LOOGLE_NOTIFY" --title "$TITLE" --body "$BODY" --category raspi_backup \
|
||||
--severity "$SEVERITY" &
|
||||
TAG="raspi_backup-${HOST}"
|
||||
SELF="$(readlink -f "${BASH_SOURCE[0]:-$0}" 2>/dev/null || echo /usr/local/bin/raspiBackup_loogle_post.sh)"
|
||||
|
||||
# Stacca dal servizio raspiBackup (KillMode=control-group) e aspetta Casa.
|
||||
if command -v systemd-run >/dev/null 2>&1; then
|
||||
systemd-run --quiet --collect \
|
||||
--description="Loogle Casa raspiBackup notify ${HOST}" \
|
||||
/bin/bash "$SELF" --wait-send "$TITLE" "$BODY" "$SEVERITY" "$TAG" \
|
||||
|| true
|
||||
else
|
||||
nohup /bin/bash "$SELF" --wait-send "$TITLE" "$BODY" "$SEVERITY" "$TAG" \
|
||||
>/dev/null 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
fi
|
||||
|
||||
return 0 2>/dev/null || exit 0
|
||||
@@ -0,0 +1,85 @@
|
||||
# Generato da .env.example — personalizza prima del deploy
|
||||
MCP_BASE_URL=https://mcp.loogle.it
|
||||
MCP_PORT=8700
|
||||
MCP_JWT_SECRET='k9#mP2$vQ8!zX5_wL3*bF7%hR4&jN6+yT1-pC9@dG2#fK5^sV8~xL4?hZ1'
|
||||
MCP_OAUTH_CLIENT_ID=loogle-mcp-public
|
||||
|
||||
PAPERLESS_URL=https://docs.loogle.it
|
||||
# Token API utente (docs.loogle.it → Profilo → Token API) — vedi docs/PAPERLESS-TOKEN.md
|
||||
# Opzionale fallback admin: PAPERLESS_API_TOKEN=
|
||||
PAPERLESS_API_TOKEN_DANIELE=d75d378c1ce654578956eb595c0436cb96213b5c
|
||||
PAPERLESS_API_TOKEN_LUCIA=b3124dab2d42f230e503adafaac7ff42551c1c56
|
||||
PAPERLESS_API_TOKEN_DAVIDE=c50ecb5780eee62d5882bff980dbed8c7cf1b860
|
||||
PAPERLESS_API_TOKEN_LUCA=45792e52a58d7f2a34fcf0f237b81770ac2f8fdc
|
||||
|
||||
# Gitea API — vedi docs/GITEA-TOKEN.md
|
||||
GITEA_URL=https://git.loogle.it
|
||||
GITEA_API_URL=http://192.168.128.81:3002
|
||||
GITEA_API_TOKEN_DANIELE=86faeed42981e855762b34f86b53dc342766fa44
|
||||
GITEA_API_TOKEN_LUCIA=478e0ac9436b412357fab144710ff74ab927e7fd
|
||||
GITEA_API_TOKEN_DAVIDE=36a097519686ce64cc1103f66691555adbc3735d
|
||||
GITEA_API_TOKEN_LUCA=051239ae1e8e60a89619249abed1b65550d29414
|
||||
|
||||
# Qdrant remoto su DS920 (consigliato) — lasciare vuoto per fallback SQLite su Pi
|
||||
QDRANT_URL=http://192.168.128.100:6333
|
||||
# Qdrant locale disabilitato su Pi5 (page size 16K) — vedi compose profile qdrant-local
|
||||
OLLAMA_URL=http://192.168.128.100:11434
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_EMBED_MODEL=text-embedding-3-small
|
||||
INDEXER_INTERVAL_MINUTES=30
|
||||
|
||||
# Thermal gate DS920 — profilo lento/regolare (1 core Ollama + delay lunghi)
|
||||
# Soft anticipato: un embed può alzare la temp di diversi °C
|
||||
THERMAL_GATE_ENABLED=yes
|
||||
THERMAL_TEMP_SOFT_C=58
|
||||
THERMAL_TEMP_HARD_C=70
|
||||
THERMAL_CPU_TARGET_PCT=40
|
||||
THERMAL_POLL_S=30
|
||||
THERMAL_RESUME_MARGIN_C=2
|
||||
DS920_THERMAL_URL=http://192.168.128.100:9191/thermal
|
||||
OLLAMA_NUM_THREAD=1
|
||||
OLLAMA_EMBED_DELAY_S=10
|
||||
OLLAMA_EMBED_COOL_DELAY_S=3
|
||||
OLLAMA_KEEP_ALIVE_DEFAULT=120
|
||||
OLLAMA_KEEP_ALIVE_COOL=300
|
||||
# no = non scaricare il modello per HARD (container sempre su; solo pausa indexer)
|
||||
OLLAMA_UNLOAD_ON_HARD=no
|
||||
|
||||
# RAG Gitea (P3)
|
||||
GITEA_INDEX_ENABLED=yes
|
||||
GITEA_INDEX_REPOS_DANIELE=daniele/rete,daniele/loogle-scripts
|
||||
GITEA_INDEX_REPOS_LUCIA=lucia/nodus,lucia/progetti
|
||||
GITEA_INDEX_REPOS_DAVIDE=davide/progetti
|
||||
GITEA_INDEX_REPOS_LUCA=luca/progetti
|
||||
GITEA_INDEX_MAX_FILES_PER_REPO=40
|
||||
GITEA_INDEX_MAX_FILE_BYTES=120000
|
||||
|
||||
# P5 — Loogle Casa + Home Assistant (read-only)
|
||||
LOOGLE_CASA_URL=https://casa.loogle.it
|
||||
LOOGLE_CASA_API_URL=http://192.168.128.81:5602
|
||||
LOOGLE_CASA_PASSWORD_DANIELE=@Dedelove1
|
||||
LOOGLE_CASA_PASSWORD_LUCIA=lucia
|
||||
LOOGLE_CASA_PASSWORD_DAVIDE=davide
|
||||
LOOGLE_CASA_PASSWORD_LUCA=luca
|
||||
|
||||
HA_URL=https://ha.loogle.it
|
||||
HA_API_URL=http://192.168.128.81:8123
|
||||
HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI0ZGVhYzczOGYzMTA0Y2QzYTQ3YTViNDRlZTY2NjNhNiIsImlhdCI6MTc4MTE2NjE3NSwiZXhwIjoyMDk2NTI2MTc1fQ.2sou8f41sEcN_gn1NxJfb1MLNftMVHAsReQB0hP-jPo
|
||||
|
||||
# P6 — Irrigazione + Turni (read-only)
|
||||
IRRIGAZIONE_URL=https://irri.loogle.it
|
||||
IRRIGAZIONE_API_URL=http://192.168.128.81:5601
|
||||
IRRIGAZIONE_PASSWORD_DANIELE=@Dedelove1
|
||||
IRRIGAZIONE_PASSWORD_LUCIA=lucia
|
||||
IRRIGAZIONE_PASSWORD_DAVIDE=dado
|
||||
IRRIGAZIONE_PASSWORD_LUCA=luca
|
||||
|
||||
TURNI_URL=https://turni.loogle.it
|
||||
TURNI_API_URL=https://turni.loogle.it
|
||||
TURNI_PASSWORD_DANIELE=@Dedelove1
|
||||
|
||||
# P7 — RAG Irrigazione + Turni
|
||||
APPS_INDEX_ENABLED=yes
|
||||
APPS_INDEX_USER=daniele
|
||||
@@ -0,0 +1,81 @@
|
||||
# Loogle MCP Hub — copia in .env e personalizza
|
||||
MCP_BASE_URL=https://mcp.loogle.it
|
||||
MCP_PORT=8700
|
||||
MCP_JWT_SECRET=change-me-to-a-long-random-string
|
||||
MCP_OAUTH_CLIENT_ID=loogle-mcp-public
|
||||
|
||||
# Paperless API — vedi docs/PAPERLESS-TOKEN.md
|
||||
PAPERLESS_URL=https://docs.loogle.it
|
||||
# Fallback admin (opzionale se usi token per utente sotto)
|
||||
PAPERLESS_API_TOKEN=
|
||||
PAPERLESS_API_TOKEN_DANIELE=
|
||||
PAPERLESS_API_TOKEN_LUCIA=
|
||||
PAPERLESS_API_TOKEN_DAVIDE=
|
||||
PAPERLESS_API_TOKEN_LUCA=
|
||||
|
||||
# Gitea API — vedi docs/GITEA-TOKEN.md
|
||||
GITEA_URL=https://git.loogle.it
|
||||
# Opzionale: URL API raggiungibile dal container MCP (es. http://192.168.128.81:3002)
|
||||
# GITEA_API_URL=
|
||||
GITEA_API_TOKEN=
|
||||
GITEA_API_TOKEN_DANIELE=
|
||||
GITEA_API_TOKEN_LUCIA=
|
||||
GITEA_API_TOKEN_DAVIDE=
|
||||
GITEA_API_TOKEN_LUCA=
|
||||
|
||||
# Qdrant (container locale o remoto)
|
||||
QDRANT_URL=http://192.168.128.100:6333
|
||||
|
||||
# Embedding via Ollama su DS920+
|
||||
OLLAMA_URL=http://192.168.128.100:11434
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||
|
||||
# Fallback cloud embedding (opzionale)
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_EMBED_MODEL=text-embedding-3-small
|
||||
|
||||
# Indicizzazione
|
||||
INDEXER_INTERVAL_MINUTES=30
|
||||
|
||||
# Thermal gate DS920 — evita overheating durante embedding
|
||||
THERMAL_GATE_ENABLED=yes
|
||||
THERMAL_TEMP_SOFT_C=58
|
||||
THERMAL_TEMP_HARD_C=70
|
||||
THERMAL_CPU_TARGET_PCT=40
|
||||
THERMAL_POLL_S=30
|
||||
THERMAL_RESUME_MARGIN_C=2
|
||||
DS920_THERMAL_URL=http://192.168.128.100:9191/thermal
|
||||
OLLAMA_NUM_THREAD=1
|
||||
OLLAMA_EMBED_DELAY_S=10
|
||||
OLLAMA_EMBED_COOL_DELAY_S=3
|
||||
|
||||
# RAG Gitea (P3) — indicizza markdown/codice da git.loogle.it
|
||||
GITEA_INDEX_ENABLED=yes
|
||||
# Opzionale: limita repo per utente (CSV). Vuoto = tutti i repo accessibili via API
|
||||
# GITEA_INDEX_REPOS_DANIELE=daniele/rete,daniele/loogle-scripts
|
||||
# GITEA_INDEX_REPOS_DAVIDE=davide/progetti
|
||||
# GITEA_INDEX_REPOS_LUCA=luca/progetti
|
||||
GITEA_INDEX_MAX_FILES_PER_REPO=150
|
||||
GITEA_INDEX_MAX_FILE_BYTES=120000
|
||||
|
||||
# P5 — Loogle Casa + Home Assistant (read-only)
|
||||
LOOGLE_CASA_URL=https://casa.loogle.it
|
||||
# LOOGLE_CASA_API_URL=http://192.168.128.81:5602
|
||||
LOOGLE_CASA_PASSWORD_DANIELE=
|
||||
LOOGLE_CASA_PASSWORD_LUCIA=
|
||||
LOOGLE_CASA_PASSWORD_DAVIDE=
|
||||
LOOGLE_CASA_PASSWORD_LUCA=
|
||||
HA_URL=https://ha.loogle.it
|
||||
# HA_API_URL=http://192.168.128.81:8123
|
||||
HA_TOKEN=
|
||||
|
||||
# P6 — Irrigazione + Turni (read-only)
|
||||
IRRIGAZIONE_URL=https://irri.loogle.it
|
||||
# IRRIGAZIONE_API_URL=http://192.168.128.81:5601
|
||||
IRRIGAZIONE_PASSWORD_DANIELE=
|
||||
TURNI_URL=https://turni.loogle.it
|
||||
TURNI_PASSWORD_DANIELE=
|
||||
|
||||
# P7 — RAG Irrigazione + Turni
|
||||
APPS_INDEX_ENABLED=yes
|
||||
APPS_INDEX_USER=daniele
|
||||
@@ -0,0 +1,4 @@
|
||||
data/
|
||||
.env
|
||||
*.pyc
|
||||
__pycache__/
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 TZ=Europe/Rome
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /srv
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY worker ./worker
|
||||
COPY scripts ./scripts
|
||||
COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
ENV PYTHONPATH=/srv
|
||||
VOLUME /data
|
||||
EXPOSE 8700
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8700"]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Loogle MCP Hub
|
||||
|
||||
Piattaforma MCP multi-utente per homelab LOOGLE.IT.
|
||||
|
||||
## Documentazione
|
||||
|
||||
| Documento | Per chi |
|
||||
|-----------|---------|
|
||||
| [ARCHITETTURA.md](docs/ARCHITETTURA.md) | Architettura, parametri, DNS, NPM, env |
|
||||
| [GUIDA-ADMIN.md](docs/GUIDA-ADMIN.md) | Daniele — gestione e uso avanzato |
|
||||
| [GUIDA-UTENTI.md](docs/GUIDA-UTENTI.md) | Lucia, Davide, Luca — client AI e uso quotidiano |
|
||||
| [ONBOARDING.md](docs/ONBOARDING.md) | Dettaglio tecnico connector MCP |
|
||||
|
||||
## URL
|
||||
|
||||
- MCP: https://mcp.loogle.it/mcp
|
||||
- Dashboard: https://mcp.loogle.it/dashboard
|
||||
|
||||
## Setup DNS + NPM
|
||||
|
||||
```bash
|
||||
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh
|
||||
```
|
||||
Whitespace-only changes.
@@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Audit log for MCP tool invocations."""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from .db import get_conn
|
||||
|
||||
|
||||
def log_tool(username: str, tool_name: str, resource_id: Optional[str] = None, detail: Optional[dict] = None) -> None:
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log(username,tool_name,resource_id,detail) VALUES (?,?,?,?)",
|
||||
(username, tool_name, resource_id, json.dumps(detail or {}, ensure_ascii=False)),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM audit_log WHERE id NOT IN "
|
||||
"(SELECT id FROM audit_log ORDER BY id DESC LIMIT 5000)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def list_audit(limit: int = 100, username: Optional[str] = None) -> list:
|
||||
conn = get_conn()
|
||||
if username:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM audit_log WHERE username=? ORDER BY id DESC LIMIT ?",
|
||||
(username, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM audit_log ORDER BY id DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -0,0 +1,146 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Autenticazione utenti famiglia — pattern Loogle Casa."""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .db import get_conn
|
||||
|
||||
SESSION_DAYS = 30
|
||||
PBKDF2_ITER = 240_000
|
||||
MAX_ATTEMPTS = 8
|
||||
WINDOW_S = 600
|
||||
_attempts: dict = {}
|
||||
|
||||
FAMILY_USERS = (
|
||||
("daniele", True),
|
||||
("lucia", False),
|
||||
("davide", False),
|
||||
("luca", False),
|
||||
)
|
||||
|
||||
DEFAULT_SCOPES = (
|
||||
"context:read context:write knowledge:read knowledge:write gitea:read gitea:write"
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = os.urandom(16)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITER)
|
||||
return "pbkdf2$%d$%s$%s" % (
|
||||
PBKDF2_ITER,
|
||||
base64.b64encode(salt).decode(),
|
||||
base64.b64encode(dk).decode(),
|
||||
)
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
_, iters, salt_b64, dk_b64 = stored.split("$")
|
||||
salt = base64.b64decode(salt_b64)
|
||||
expected = base64.b64decode(dk_b64)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, int(iters))
|
||||
return hmac.compare_digest(dk, expected)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_family_users() -> None:
|
||||
conn = get_conn()
|
||||
for username, is_admin in FAMILY_USERS:
|
||||
row = conn.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()
|
||||
if row:
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO users(username,password_hash,is_admin,must_change_password)"
|
||||
" VALUES (?,?,?,1)",
|
||||
(username, hash_password(username), 1 if is_admin else 0),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def throttle(ip: str) -> None:
|
||||
now = time.time()
|
||||
hist = [t for t in _attempts.get(ip, []) if now - t < WINDOW_S]
|
||||
_attempts[ip] = hist
|
||||
if len(hist) >= MAX_ATTEMPTS:
|
||||
raise HTTPException(429, "Troppi tentativi: riprova tra qualche minuto")
|
||||
|
||||
|
||||
def record_attempt(ip: str) -> None:
|
||||
_attempts.setdefault(ip, []).append(time.time())
|
||||
|
||||
|
||||
def authenticate(username: str, password: str) -> Optional[dict]:
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone()
|
||||
if not row or not verify_password(password, row["password_hash"]):
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
def get_user_by_id(user_id: int) -> Optional[dict]:
|
||||
row = get_conn().execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_user_by_username(username: str) -> Optional[dict]:
|
||||
row = get_conn().execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def change_password(user_id: int, old_password: str, new_password: str) -> bool:
|
||||
row = get_conn().execute("SELECT password_hash FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row or not verify_password(old_password, row["password_hash"]):
|
||||
return False
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash=?, must_change_password=0 WHERE id=?",
|
||||
(hash_password(new_password), user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
|
||||
def current_user_from_cookie(request: Request) -> dict:
|
||||
token = request.cookies.get("mcp_session", "")
|
||||
if not token:
|
||||
raise HTTPException(401, "Non autenticato")
|
||||
row = get_conn().execute(
|
||||
"SELECT u.id,u.username,u.is_admin,u.must_change_password"
|
||||
" FROM sessions s JOIN users u ON u.id=s.user_id"
|
||||
" WHERE s.token=? AND s.expires_at > datetime('now')",
|
||||
(token,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(401, "Sessione scaduta")
|
||||
return dict(row)
|
||||
|
||||
|
||||
def require_admin(request: Request) -> dict:
|
||||
user = current_user_from_cookie(request)
|
||||
if not user["is_admin"]:
|
||||
raise HTTPException(403, "Riservato all'amministratore")
|
||||
return user
|
||||
|
||||
|
||||
def ensure_sessions_table() -> None:
|
||||
get_conn().execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
get_conn().commit()
|
||||
Whitespace-only changes.
@@ -0,0 +1,154 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Collegamento progetti MCP ↔ repository Gitea."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from ..knowledge import gitea
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.context.gitea_link")
|
||||
|
||||
README_CANDIDATES = ("README.md", "readme.md", "Readme.md", "README.MD")
|
||||
DOCS_DIR = "docs"
|
||||
MAX_README_CHARS = 12_000
|
||||
MAX_DOC_FILES = 25
|
||||
|
||||
|
||||
def normalize_gitea_repo(repo: Optional[str]) -> Optional[str]:
|
||||
if repo is None:
|
||||
return None
|
||||
cleaned = repo.strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
owner, name = gitea.parse_repo(cleaned)
|
||||
return f"{owner}/{name}"
|
||||
|
||||
|
||||
def verify_repo_access(username: str, repo: str) -> None:
|
||||
owner, name = gitea.parse_repo(repo)
|
||||
gitea._request("GET", f"/repos/{owner}/{name}", username=username)
|
||||
|
||||
|
||||
def fetch_readme(username: str, repo: str) -> Optional[dict]:
|
||||
for path in README_CANDIDATES:
|
||||
try:
|
||||
data = gitea.get_file(repo, path, username=username)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except Exception as exc:
|
||||
LOGGER.warning("README %s/%s non leggibile: %s", repo, path, exc)
|
||||
continue
|
||||
if data.get("type") != "file":
|
||||
continue
|
||||
content = (data.get("content") or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
truncated = len(content) > MAX_README_CHARS
|
||||
return {
|
||||
"path": path,
|
||||
"sha": data.get("sha"),
|
||||
"html_url": data.get("html_url"),
|
||||
"content": content[:MAX_README_CHARS],
|
||||
"truncated": truncated,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def fetch_docs_index(username: str, repo: str) -> list[dict]:
|
||||
try:
|
||||
data = gitea.get_file(repo, DOCS_DIR, username=username)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Directory docs/ non leggibile per %s: %s", repo, exc)
|
||||
return []
|
||||
|
||||
if data.get("type") != "dir":
|
||||
return []
|
||||
|
||||
entries = []
|
||||
for item in data.get("entries") or []:
|
||||
if item.get("type") != "file":
|
||||
continue
|
||||
path = item.get("path") or ""
|
||||
if not path.lower().endswith((".md", ".txt", ".rst")):
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"path": path,
|
||||
"size": item.get("size"),
|
||||
}
|
||||
)
|
||||
if len(entries) >= MAX_DOC_FILES:
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def project_enrichment(username: str, gitea_repo: str) -> dict:
|
||||
repo = normalize_gitea_repo(gitea_repo)
|
||||
if not repo:
|
||||
return {"linked": False}
|
||||
|
||||
base = {
|
||||
"linked": True,
|
||||
"repo": repo,
|
||||
"html_url": f"{gitea.public_base_url()}/{repo}",
|
||||
}
|
||||
|
||||
if not gitea.is_configured(username):
|
||||
return {
|
||||
**base,
|
||||
"available": False,
|
||||
"error": "Gitea non configurato per questo utente",
|
||||
}
|
||||
|
||||
try:
|
||||
verify_repo_access(username, repo)
|
||||
readme = fetch_readme(username, repo)
|
||||
docs = fetch_docs_index(username, repo)
|
||||
open_issues = None
|
||||
try:
|
||||
issues = gitea.list_issues(repo, state="open", limit=1, username=username)
|
||||
open_issues = issues.get("count", 0)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
**base,
|
||||
"available": True,
|
||||
"readme": readme,
|
||||
"docs_files": docs,
|
||||
"open_issues": open_issues,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
**base,
|
||||
"available": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def readme_seed_block(repo: str, readme: dict) -> str:
|
||||
path = readme.get("path") or "README.md"
|
||||
content = readme.get("content") or ""
|
||||
truncated_note = "\n\n*(README troncato — usa get_file per il testo completo)*" if readme.get("truncated") else ""
|
||||
return (
|
||||
f"<!-- seed:gitea {repo} {path} -->\n\n"
|
||||
f"## Sorgente Gitea: `{repo}`\n\n"
|
||||
f"Contenuto iniziale da `{path}`.\n\n"
|
||||
f"{content.rstrip()}{truncated_note}\n"
|
||||
)
|
||||
|
||||
|
||||
def seed_context_from_readme(username: str, repo: str, context_md: str) -> tuple[str, bool]:
|
||||
"""Importa README nel context se non già presente un seed Gitea."""
|
||||
if "<!-- seed:gitea " in context_md:
|
||||
return context_md, False
|
||||
readme = fetch_readme(username, repo)
|
||||
if not readme:
|
||||
return context_md, False
|
||||
block = readme_seed_block(repo, readme)
|
||||
if context_md.strip():
|
||||
return context_md.rstrip() + "\n\n---\n\n" + block, True
|
||||
return block, True
|
||||
@@ -0,0 +1,252 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Filesystem-backed project context store."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from . import gitea_link
|
||||
from ..knowledge import gitea
|
||||
|
||||
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
|
||||
|
||||
def _context_root() -> str:
|
||||
return os.environ.get("MCP_CONTEXT_ROOT", "/data/context")
|
||||
|
||||
|
||||
def _user_root(username: str) -> str:
|
||||
path = os.path.join(_context_root(), username, "projects")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _project_dir(username: str, project_id: str) -> str:
|
||||
if not SLUG_RE.match(project_id):
|
||||
raise ValueError("ID progetto non valido")
|
||||
return os.path.join(_user_root(username), project_id)
|
||||
|
||||
|
||||
def _load_meta(username: str, project_id: str) -> tuple[str, dict]:
|
||||
proj_dir = _project_dir(username, project_id)
|
||||
meta_path = os.path.join(proj_dir, "meta.json")
|
||||
if not os.path.isfile(meta_path):
|
||||
raise FileNotFoundError("Progetto non trovato")
|
||||
meta = json.load(open(meta_path, encoding="utf-8"))
|
||||
return meta_path, meta
|
||||
|
||||
|
||||
def _save_meta(meta_path: str, meta: dict) -> None:
|
||||
meta["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _apply_gitea_link(
|
||||
username: str,
|
||||
meta: dict,
|
||||
gitea_repo: Optional[str],
|
||||
*,
|
||||
verify: bool = True,
|
||||
seed_from_gitea: bool = False,
|
||||
) -> dict:
|
||||
repo = gitea_link.normalize_gitea_repo(gitea_repo)
|
||||
if verify and repo:
|
||||
if not gitea.is_configured(username):
|
||||
raise RuntimeError(
|
||||
"Gitea non configurato per questo utente — impossibile collegare il repository"
|
||||
)
|
||||
gitea_link.verify_repo_access(username, repo)
|
||||
|
||||
if repo:
|
||||
meta["gitea_repo"] = repo
|
||||
else:
|
||||
meta.pop("gitea_repo", None)
|
||||
|
||||
if seed_from_gitea and repo:
|
||||
proj_dir = _project_dir(username, meta["id"])
|
||||
ctx_path = os.path.join(proj_dir, "context.md")
|
||||
context_md = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
|
||||
new_md, changed = gitea_link.seed_context_from_readme(username, repo, context_md)
|
||||
if changed:
|
||||
with open(ctx_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_md)
|
||||
meta["gitea_seeded_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
return meta
|
||||
|
||||
|
||||
def list_projects(username: str, include_archived: bool = False) -> list:
|
||||
root = _user_root(username)
|
||||
projects = []
|
||||
if not os.path.isdir(root):
|
||||
return projects
|
||||
for name in sorted(os.listdir(root)):
|
||||
meta_path = os.path.join(root, name, "meta.json")
|
||||
if not os.path.isfile(meta_path):
|
||||
continue
|
||||
meta = json.load(open(meta_path, encoding="utf-8"))
|
||||
if meta.get("archived") and not include_archived:
|
||||
continue
|
||||
projects.append(meta)
|
||||
return projects
|
||||
|
||||
|
||||
def create_project(
|
||||
username: str,
|
||||
title: str,
|
||||
tags: Optional[list[str]] = None,
|
||||
gitea_repo: Optional[str] = None,
|
||||
seed_from_gitea: bool = False,
|
||||
) -> dict:
|
||||
slug_base = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "progetto"
|
||||
project_id = slug_base[:40]
|
||||
root = _user_root(username)
|
||||
while os.path.exists(os.path.join(root, project_id)):
|
||||
project_id = f"{slug_base[:32]}-{secrets.token_hex(2)}"
|
||||
proj_dir = _project_dir(username, project_id)
|
||||
os.makedirs(os.path.join(proj_dir, "sessions"), exist_ok=True)
|
||||
os.makedirs(os.path.join(proj_dir, "artifacts"), exist_ok=True)
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
meta = {
|
||||
"id": project_id,
|
||||
"title": title.strip(),
|
||||
"tags": tags or [],
|
||||
"status": "active",
|
||||
"archived": False,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
repo = gitea_link.normalize_gitea_repo(gitea_repo)
|
||||
if repo:
|
||||
meta["gitea_repo"] = repo
|
||||
|
||||
with open(os.path.join(proj_dir, "meta.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
with open(os.path.join(proj_dir, "context.md"), "w", encoding="utf-8") as f:
|
||||
f.write(f"# {title.strip()}\n\n")
|
||||
|
||||
if repo:
|
||||
meta = _apply_gitea_link(
|
||||
username,
|
||||
meta,
|
||||
repo,
|
||||
verify=True,
|
||||
seed_from_gitea=seed_from_gitea,
|
||||
)
|
||||
_save_meta(os.path.join(proj_dir, "meta.json"), meta)
|
||||
|
||||
return meta
|
||||
|
||||
|
||||
def link_project_repo(
|
||||
username: str,
|
||||
project_id: str,
|
||||
gitea_repo: Optional[str] = None,
|
||||
seed_from_gitea: bool = False,
|
||||
) -> dict:
|
||||
meta_path, meta = _load_meta(username, project_id)
|
||||
meta = _apply_gitea_link(
|
||||
username,
|
||||
meta,
|
||||
gitea_repo,
|
||||
verify=bool(gitea_link.normalize_gitea_repo(gitea_repo)),
|
||||
seed_from_gitea=seed_from_gitea,
|
||||
)
|
||||
_save_meta(meta_path, meta)
|
||||
return meta
|
||||
|
||||
|
||||
def get_project_context(
|
||||
username: str,
|
||||
project_id: str,
|
||||
session_limit: int = 5,
|
||||
include_gitea: bool = True,
|
||||
) -> dict:
|
||||
proj_dir = _project_dir(username, project_id)
|
||||
meta_path = os.path.join(proj_dir, "meta.json")
|
||||
if not os.path.isfile(meta_path):
|
||||
raise FileNotFoundError("Progetto non trovato")
|
||||
meta = json.load(open(meta_path, encoding="utf-8"))
|
||||
ctx_path = os.path.join(proj_dir, "context.md")
|
||||
context_md = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
|
||||
sessions_dir = os.path.join(proj_dir, "sessions")
|
||||
sessions = []
|
||||
if os.path.isdir(sessions_dir):
|
||||
files = sorted(os.listdir(sessions_dir), reverse=True)[:session_limit]
|
||||
for fname in files:
|
||||
path = os.path.join(sessions_dir, fname)
|
||||
if os.path.isfile(path):
|
||||
sessions.append({"name": fname, "content": open(path, encoding="utf-8").read()})
|
||||
|
||||
result = {"meta": meta, "context_md": context_md, "recent_sessions": sessions}
|
||||
gitea_repo = meta.get("gitea_repo")
|
||||
if include_gitea and gitea_repo:
|
||||
result["gitea"] = gitea_link.project_enrichment(username, gitea_repo)
|
||||
return result
|
||||
|
||||
|
||||
def save_context(username: str, project_id: str, content: str, mode: str = "append") -> dict:
|
||||
proj_dir = _project_dir(username, project_id)
|
||||
meta_path = os.path.join(proj_dir, "meta.json")
|
||||
if not os.path.isfile(meta_path):
|
||||
raise FileNotFoundError("Progetto non trovato")
|
||||
ctx_path = os.path.join(proj_dir, "context.md")
|
||||
if mode == "replace":
|
||||
text = content
|
||||
else:
|
||||
existing = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
|
||||
text = existing.rstrip() + "\n\n" + content.strip() + "\n"
|
||||
with open(ctx_path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
meta = json.load(open(meta_path, encoding="utf-8"))
|
||||
_save_meta(meta_path, meta)
|
||||
snapshot = datetime.now().strftime("%Y%m%d-%H%M%S") + ".md"
|
||||
with open(os.path.join(proj_dir, "sessions", snapshot), "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return meta
|
||||
|
||||
|
||||
def archive_project(username: str, project_id: str, archived: bool = True) -> dict:
|
||||
meta_path, meta = _load_meta(username, project_id)
|
||||
meta["archived"] = archived
|
||||
meta["status"] = "archived" if archived else "active"
|
||||
_save_meta(meta_path, meta)
|
||||
return meta
|
||||
|
||||
|
||||
def list_resources(username: str) -> list:
|
||||
resources = []
|
||||
for meta in list_projects(username, include_archived=False):
|
||||
desc = f"Contesto progetto {meta['id']}"
|
||||
if meta.get("gitea_repo"):
|
||||
desc += f" (Gitea: {meta['gitea_repo']})"
|
||||
resources.append(
|
||||
{
|
||||
"uri": f"loogle://context/{username}/{meta['id']}",
|
||||
"name": meta["title"],
|
||||
"description": desc,
|
||||
"mimeType": "text/markdown",
|
||||
}
|
||||
)
|
||||
return resources
|
||||
|
||||
|
||||
def read_resource(username: str, uri: str) -> dict:
|
||||
prefix = f"loogle://context/{username}/"
|
||||
if not uri.startswith(prefix):
|
||||
raise FileNotFoundError("Risorsa non trovata")
|
||||
project_id = uri[len(prefix):]
|
||||
data = get_project_context(username, project_id, session_limit=0, include_gitea=False)
|
||||
text = data["context_md"]
|
||||
gitea_repo = data["meta"].get("gitea_repo")
|
||||
if gitea_repo:
|
||||
text = f"<!-- gitea_repo: {gitea_repo} -->\n\n" + text
|
||||
return {
|
||||
"uri": uri,
|
||||
"mimeType": "text/markdown",
|
||||
"text": text,
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SQLite schema for Loogle MCP Hub."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
DB_PATH = os.environ.get("MCP_DB", "/data/loogle_mcp.db")
|
||||
_local = threading.local()
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
client_name TEXT NOT NULL,
|
||||
redirect_uris TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
code_challenge TEXT,
|
||||
code_challenge_method TEXT,
|
||||
expires_at TEXT NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
client_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revoked_jtis (
|
||||
jti TEXT PRIMARY KEY,
|
||||
revoked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS indexed_documents (
|
||||
doc_id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
owner TEXT,
|
||||
visibility TEXT NOT NULL DEFAULT 'family',
|
||||
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS indexed_gitea_files (
|
||||
repo TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
sha TEXT,
|
||||
owner TEXT NOT NULL,
|
||||
visibility TEXT NOT NULL DEFAULT 'family',
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (repo, path)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gitea_indexed_repo ON indexed_gitea_files(repo);
|
||||
CREATE INDEX IF NOT EXISTS idx_gitea_indexed_at ON indexed_gitea_files(indexed_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS indexed_apps_records (
|
||||
source TEXT NOT NULL,
|
||||
record_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
owner TEXT NOT NULL DEFAULT 'family',
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (source, record_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_indexed_source ON indexed_apps_records(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_indexed_at ON indexed_apps_records(indexed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_at);
|
||||
"""
|
||||
|
||||
|
||||
def get_conn() -> sqlite3.Connection:
|
||||
conn = getattr(_local, "conn", None)
|
||||
if conn is None:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
_local.conn = conn
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
get_conn().executescript(SCHEMA)
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Client REST verso app homelab LOOGLE."""
|
||||
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Client Loogle Casa — dashboard, meteo, rete."""
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from .session_client import SessionApiClient
|
||||
|
||||
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||
# Daniele MCP → admin su Loogle Casa
|
||||
MCP_TO_SERVICE_USER = {
|
||||
"daniele": "admin",
|
||||
"lucia": "lucia",
|
||||
"davide": "davide",
|
||||
"luca": "luca",
|
||||
}
|
||||
|
||||
|
||||
def _service_username(mcp_username: str) -> str:
|
||||
return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower())
|
||||
PUBLIC_URL = os.environ.get("LOOGLE_CASA_URL", "https://casa.loogle.it").rstrip("/")
|
||||
API_URL = os.environ.get("LOOGLE_CASA_API_URL", PUBLIC_URL).rstrip("/")
|
||||
|
||||
_client: Optional[SessionApiClient] = None
|
||||
|
||||
|
||||
def _client_instance() -> SessionApiClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
verify = os.environ.get("LOOGLE_CASA_VERIFY_SSL", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
client = SessionApiClient(
|
||||
service="LOOGLE_CASA",
|
||||
base_url=API_URL,
|
||||
verify_ssl=verify,
|
||||
)
|
||||
client.map_username = _service_username # type: ignore[attr-defined]
|
||||
_client = client
|
||||
return _client
|
||||
|
||||
|
||||
def is_configured(username: Optional[str] = None) -> bool:
|
||||
user = (username or "daniele").lower()
|
||||
if user in MCP_USERS:
|
||||
key = f"LOOGLE_CASA_PASSWORD_{user.upper()}"
|
||||
if os.environ.get(key, "").strip():
|
||||
return True
|
||||
if os.environ.get("LOOGLE_CASA_PASSWORD", "").strip():
|
||||
return True
|
||||
return user in MCP_USERS
|
||||
|
||||
|
||||
def get_dashboard(username: str) -> dict:
|
||||
return _client_instance().get("/api/dashboard", username=username)
|
||||
|
||||
|
||||
def get_weather_home(username: str) -> dict:
|
||||
return _client_instance().get("/api/weather/home", username=username)
|
||||
|
||||
|
||||
def get_network_overview(username: str) -> dict:
|
||||
return _client_instance().get("/api/network/overview", username=username)
|
||||
|
||||
|
||||
def get_network_failover_status(username: str) -> dict:
|
||||
return _client_instance().get("/api/network/failover/status", username=username)
|
||||
|
||||
|
||||
def get_network_mcp_status(username: str) -> dict:
|
||||
return _client_instance().get("/api/network/mcp", username=username)
|
||||
|
||||
|
||||
def get_alerts_cards(username: str) -> Any:
|
||||
return _client_instance().get("/api/alerts/cards", username=username)
|
||||
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Client Home Assistant REST API (read-only)."""
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
PUBLIC_URL = os.environ.get("HA_URL", "https://ha.loogle.it").rstrip("/")
|
||||
API_URL = os.environ.get("HA_API_URL", PUBLIC_URL).rstrip("/")
|
||||
HA_TOKEN = os.environ.get("HA_TOKEN", "").strip()
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
return bool(HA_TOKEN)
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
if not HA_TOKEN:
|
||||
raise RuntimeError(
|
||||
"HA_TOKEN non configurato in .env — crea un long-lived token in Home Assistant"
|
||||
)
|
||||
return {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _request(method: str, path: str, *, params: Optional[dict] = None) -> Any:
|
||||
verify = os.environ.get("HA_VERIFY_SSL", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
url = path if path.startswith("http") else urljoin(API_URL + "/", path.lstrip("/"))
|
||||
with httpx.Client(timeout=30.0, verify=verify) as client:
|
||||
resp = client.request(method, url, headers=_headers(), params=params)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"Home Assistant {path}: HTTP {resp.status_code} {resp.text[:200]}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
def get_config() -> dict:
|
||||
return _request("GET", "/api/config")
|
||||
|
||||
|
||||
def get_entity(entity_id: str) -> dict:
|
||||
return _request("GET", f"/api/states/{entity_id}")
|
||||
|
||||
|
||||
def list_entities(domain: Optional[str] = None, limit: int = 100) -> list:
|
||||
states = _request("GET", "/api/states")
|
||||
if domain:
|
||||
prefix = domain if domain.endswith(".") else f"{domain}."
|
||||
states = [s for s in states if s.get("entity_id", "").startswith(prefix)]
|
||||
return states[:limit]
|
||||
|
||||
|
||||
def search_entities(query: str, limit: int = 30) -> list:
|
||||
q = query.lower()
|
||||
matches = []
|
||||
for state in _request("GET", "/api/states"):
|
||||
eid = state.get("entity_id", "")
|
||||
name = (state.get("attributes") or {}).get("friendly_name", "")
|
||||
blob = f"{eid} {name}".lower()
|
||||
if q in blob:
|
||||
matches.append({
|
||||
"entity_id": eid,
|
||||
"state": state.get("state"),
|
||||
"friendly_name": name,
|
||||
"last_changed": state.get("last_changed"),
|
||||
})
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
return matches
|
||||
@@ -0,0 +1,78 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Client Irrigazione Smart — irri.loogle.it."""
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from .session_client import SessionApiClient
|
||||
|
||||
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||
MCP_TO_SERVICE_USER = {
|
||||
"daniele": "admin",
|
||||
"lucia": "lucia",
|
||||
"davide": "dado",
|
||||
"luca": "luca",
|
||||
}
|
||||
|
||||
PUBLIC_URL = os.environ.get("IRRIGAZIONE_URL", "https://irri.loogle.it").rstrip("/")
|
||||
API_URL = os.environ.get("IRRIGAZIONE_API_URL", PUBLIC_URL).rstrip("/")
|
||||
|
||||
_client: Optional[SessionApiClient] = None
|
||||
|
||||
|
||||
def _service_username(mcp_username: str) -> str:
|
||||
return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower())
|
||||
|
||||
|
||||
def _client_instance() -> SessionApiClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
verify = os.environ.get("IRRIGAZIONE_VERIFY_SSL", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
client = SessionApiClient(
|
||||
service="IRRIGAZIONE",
|
||||
base_url=API_URL,
|
||||
verify_ssl=verify,
|
||||
)
|
||||
client.map_username = _service_username # type: ignore[attr-defined]
|
||||
_client = client
|
||||
return _client
|
||||
|
||||
|
||||
def is_configured(username: Optional[str] = None) -> bool:
|
||||
user = (username or "daniele").lower()
|
||||
if os.environ.get(f"IRRIGAZIONE_PASSWORD_{user.upper()}", "").strip():
|
||||
return True
|
||||
if os.environ.get("IRRIGAZIONE_PASSWORD", "").strip():
|
||||
return True
|
||||
return user in MCP_USERS
|
||||
|
||||
|
||||
def get_status(username: str) -> dict:
|
||||
return _client_instance().get("/api/status", username=username)
|
||||
|
||||
|
||||
def get_zones(username: str) -> Any:
|
||||
return _client_instance().get("/api/zones", username=username)
|
||||
|
||||
|
||||
def get_history(username: str, limit: int = 30) -> Any:
|
||||
data = _client_instance().get("/api/history", username=username)
|
||||
if isinstance(data, list):
|
||||
return data[:limit]
|
||||
if isinstance(data, dict) and "items" in data:
|
||||
items = data["items"]
|
||||
return items[:limit] if isinstance(items, list) else data
|
||||
return data
|
||||
|
||||
|
||||
def get_events(username: str, limit: int = 50) -> Any:
|
||||
data = _client_instance().get("/api/events", username=username)
|
||||
if isinstance(data, list):
|
||||
return data[:limit]
|
||||
return data
|
||||
|
||||
|
||||
def get_lavori_summary(username: str) -> Any:
|
||||
return _client_instance().get("/api/lavori/summary", username=username)
|
||||
@@ -0,0 +1,122 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Client HTTP con sessione cookie (Loogle Casa, Irrigazione)."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.session_client")
|
||||
|
||||
_sessions: dict[str, tuple[str, float]] = {}
|
||||
_sessions_lock = threading.Lock()
|
||||
SESSION_TTL = 3600 * 12
|
||||
|
||||
|
||||
class SessionApiClient:
|
||||
"""Login cookie-based con cache per utente MCP."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
service: str,
|
||||
base_url: str,
|
||||
login_path: str = "/api/login",
|
||||
verify_ssl: bool = True,
|
||||
) -> None:
|
||||
self.service = service
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.login_path = login_path
|
||||
self.verify_ssl = verify_ssl
|
||||
|
||||
def _password_for_user(self, username: str) -> Optional[str]:
|
||||
import os
|
||||
user = username.lower()
|
||||
env_key = f"{self.service}_PASSWORD_{user.upper()}"
|
||||
pwd = os.environ.get(env_key, "").strip()
|
||||
if pwd:
|
||||
return pwd
|
||||
fallback = os.environ.get(f"{self.service}_PASSWORD", "").strip()
|
||||
if fallback:
|
||||
return fallback
|
||||
return user
|
||||
|
||||
def _cache_key(self, username: str) -> str:
|
||||
return f"{self.service}:{username.lower()}"
|
||||
|
||||
def _get_cached_cookie(self, username: str) -> Optional[str]:
|
||||
key = self._cache_key(username)
|
||||
with _sessions_lock:
|
||||
row = _sessions.get(key)
|
||||
if not row:
|
||||
return None
|
||||
cookie, expires = row
|
||||
if time.time() > expires:
|
||||
_sessions.pop(key, None)
|
||||
return None
|
||||
return cookie
|
||||
|
||||
def _store_cookie(self, username: str, cookie: str) -> None:
|
||||
key = self._cache_key(username)
|
||||
with _sessions_lock:
|
||||
_sessions[key] = (cookie, time.time() + SESSION_TTL)
|
||||
|
||||
def login(self, username: str) -> str:
|
||||
cached = self._get_cached_cookie(username)
|
||||
if cached:
|
||||
return cached
|
||||
service_user = username
|
||||
if hasattr(self, "map_username"):
|
||||
service_user = self.map_username(username) # type: ignore[attr-defined]
|
||||
password = self._password_for_user(username)
|
||||
if not password:
|
||||
raise RuntimeError(
|
||||
f"Password {self.service} non configurata per {username}. "
|
||||
f"Imposta {self.service}_PASSWORD_{username.upper()} in .env"
|
||||
)
|
||||
url = urljoin(self.base_url + "/", self.login_path.lstrip("/"))
|
||||
with httpx.Client(timeout=30.0, verify=self.verify_ssl) as client:
|
||||
resp = client.post(
|
||||
url, json={"username": service_user, "password": password},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"Login {self.service} fallito per {username}: HTTP {resp.status_code}"
|
||||
)
|
||||
cookie = resp.cookies.get("session")
|
||||
if not cookie:
|
||||
raise RuntimeError(f"Login {self.service}: cookie session mancante")
|
||||
self._store_cookie(username, cookie)
|
||||
return cookie
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
username: str,
|
||||
params: Optional[dict] = None,
|
||||
json_body: Optional[dict] = None,
|
||||
) -> Any:
|
||||
cookie = self.login(username)
|
||||
url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/"))
|
||||
headers = {"Cookie": f"session={cookie}"}
|
||||
with httpx.Client(timeout=60.0, verify=self.verify_ssl) as client:
|
||||
resp = client.request(method, url, headers=headers, params=params, json=json_body)
|
||||
if resp.status_code == 401:
|
||||
with _sessions_lock:
|
||||
_sessions.pop(self._cache_key(username), None)
|
||||
cookie = self.login(username)
|
||||
headers = {"Cookie": f"session={cookie}"}
|
||||
resp = client.request(method, url, headers=headers, params=params, json=json_body)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"{self.service} {method} {path}: HTTP {resp.status_code} {resp.text[:200]}")
|
||||
if resp.headers.get("content-type", "").startswith("application/json"):
|
||||
return resp.json()
|
||||
return resp.text
|
||||
|
||||
def get(self, path: str, *, username: str, params: Optional[dict] = None) -> Any:
|
||||
return self.request("GET", path, username=username, params=params)
|
||||
@@ -0,0 +1,208 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Client Turni-Live — turni.loogle.it (JWT Bearer)."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.turni")
|
||||
|
||||
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||
MCP_TO_SERVICE_USER = {
|
||||
"daniele": "daniely",
|
||||
"lucia": "lucia",
|
||||
"davide": "davide",
|
||||
"luca": "luca",
|
||||
}
|
||||
|
||||
PUBLIC_URL = os.environ.get("TURNI_URL", "https://turni.loogle.it").rstrip("/")
|
||||
API_URL = os.environ.get("TURNI_API_URL", PUBLIC_URL).rstrip("/")
|
||||
|
||||
_jwt_cache: dict[str, tuple[str, float]] = {}
|
||||
_jwt_lock = threading.Lock()
|
||||
JWT_TTL = 3600 * 6
|
||||
|
||||
|
||||
def _service_username(mcp_username: str) -> str:
|
||||
return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower())
|
||||
|
||||
|
||||
def _password_for_user(mcp_username: str) -> Optional[str]:
|
||||
user = mcp_username.lower()
|
||||
pwd = os.environ.get(f"TURNI_PASSWORD_{user.upper()}", "").strip()
|
||||
if pwd:
|
||||
return pwd
|
||||
return os.environ.get("TURNI_PASSWORD", "").strip() or None
|
||||
|
||||
|
||||
def _jwt_for_user(mcp_username: str) -> Optional[str]:
|
||||
user = mcp_username.lower()
|
||||
direct = os.environ.get(f"TURNI_JWT_{user.upper()}", "").strip()
|
||||
if direct:
|
||||
return direct
|
||||
return os.environ.get("TURNI_JWT", "").strip() or None
|
||||
|
||||
|
||||
def is_configured(username: Optional[str] = None) -> bool:
|
||||
user = (username or "daniele").lower()
|
||||
if _jwt_for_user(user):
|
||||
return True
|
||||
if _password_for_user(user):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _store_jwt(mcp_username: str, token: str) -> None:
|
||||
with _jwt_lock:
|
||||
_jwt_cache[mcp_username.lower()] = (token, time.time() + JWT_TTL)
|
||||
|
||||
|
||||
def _cached_jwt(mcp_username: str) -> Optional[str]:
|
||||
with _jwt_lock:
|
||||
row = _jwt_cache.get(mcp_username.lower())
|
||||
if not row:
|
||||
return None
|
||||
token, expires = row
|
||||
if time.time() > expires:
|
||||
_jwt_cache.pop(mcp_username.lower(), None)
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def login(mcp_username: str) -> str:
|
||||
cached = _cached_jwt(mcp_username)
|
||||
if cached:
|
||||
return cached
|
||||
preset = _jwt_for_user(mcp_username)
|
||||
if preset:
|
||||
_store_jwt(mcp_username, preset)
|
||||
return preset
|
||||
password = _password_for_user(mcp_username)
|
||||
if not password:
|
||||
raise RuntimeError(
|
||||
f"Turni non configurato per {mcp_username}. "
|
||||
f"Imposta TURNI_PASSWORD_{mcp_username.upper()} o TURNI_JWT_{mcp_username.upper()}"
|
||||
)
|
||||
service_user = _service_username(mcp_username)
|
||||
verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
url = urljoin(API_URL + "/", "api/auth/login")
|
||||
with httpx.Client(timeout=30.0, verify=verify) as client:
|
||||
resp = client.post(url, json={"username": service_user, "password": password})
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"Login Turni fallito: HTTP {resp.status_code}")
|
||||
data = resp.json()
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
raise RuntimeError("Login Turni: token JWT mancante")
|
||||
_store_jwt(mcp_username, token)
|
||||
return token
|
||||
|
||||
|
||||
def _request(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
mcp_username: str,
|
||||
params: Optional[dict] = None,
|
||||
) -> Any:
|
||||
token = login(mcp_username)
|
||||
verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
url = path if path.startswith("http") else urljoin(API_URL + "/", path.lstrip("/"))
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
with httpx.Client(timeout=60.0, verify=verify) as client:
|
||||
resp = client.request(method, url, headers=headers, params=params)
|
||||
if resp.status_code == 401:
|
||||
with _jwt_lock:
|
||||
_jwt_cache.pop(mcp_username.lower(), None)
|
||||
headers["Authorization"] = f"Bearer {login(mcp_username)}"
|
||||
resp = client.request(method, url, headers=headers, params=params)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"Turni {path}: HTTP {resp.status_code} {resp.text[:200]}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
def get_status() -> dict:
|
||||
verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
url = urljoin(API_URL + "/", "api/status")
|
||||
with httpx.Client(timeout=30.0, verify=verify) as client:
|
||||
resp = client.get(url)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def list_doctors(mcp_username: str) -> Any:
|
||||
return _request("GET", "/api/doctors", mcp_username=mcp_username)
|
||||
|
||||
|
||||
def get_shift_assignments(
|
||||
mcp_username: str,
|
||||
*,
|
||||
from_date: Optional[str] = None,
|
||||
to_date: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
) -> Any:
|
||||
params: dict = {}
|
||||
if from_date:
|
||||
params["from"] = from_date
|
||||
if to_date:
|
||||
params["to"] = to_date
|
||||
data = _request("GET", "/api/shift-assignments", mcp_username=mcp_username, params=params or None)
|
||||
if isinstance(data, list):
|
||||
return data[:limit]
|
||||
if isinstance(data, dict):
|
||||
items = data.get("assignments") or data.get("items") or data.get("results")
|
||||
if isinstance(items, list):
|
||||
return items[:limit]
|
||||
return data
|
||||
|
||||
|
||||
def get_my_shifts(
|
||||
mcp_username: str,
|
||||
*,
|
||||
from_date: Optional[str] = None,
|
||||
to_date: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
) -> dict:
|
||||
"""Turni dell'utente MCP: filtra per doctorId collegato o per nome medico."""
|
||||
user_info = _request("GET", "/api/users/me", mcp_username=mcp_username)
|
||||
doctor_id = user_info.get("doctorId")
|
||||
assignments = get_shift_assignments(
|
||||
mcp_username, from_date=from_date, to_date=to_date, limit=500,
|
||||
)
|
||||
if not isinstance(assignments, list):
|
||||
return {"user": user_info, "assignments": assignments}
|
||||
if doctor_id:
|
||||
mine = [a for a in assignments if a.get("doctorId") == doctor_id or a.get("doctor_id") == doctor_id]
|
||||
else:
|
||||
service_user = _service_username(mcp_username)
|
||||
doctors = list_doctors(mcp_username)
|
||||
doc_ids = set()
|
||||
if isinstance(doctors, list):
|
||||
for doc in doctors:
|
||||
name = (doc.get("name") or doc.get("fullName") or "").lower()
|
||||
if service_user.lower() in name or mcp_username.lower() in name:
|
||||
doc_ids.add(doc.get("id") or doc.get("doctorId"))
|
||||
mine = [
|
||||
a for a in assignments
|
||||
if (a.get("doctorId") or a.get("doctor_id")) in doc_ids
|
||||
] if doc_ids else assignments[:limit]
|
||||
return {
|
||||
"user": {
|
||||
"username": user_info.get("username"),
|
||||
"role": user_info.get("role"),
|
||||
"doctorId": doctor_id,
|
||||
},
|
||||
"assignments": mine[:limit],
|
||||
"count": len(mine),
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""JWT utilities for OAuth access tokens."""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import secrets
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
|
||||
from .db import get_conn
|
||||
|
||||
JWT_ALG = "HS256"
|
||||
ACCESS_TOKEN_HOURS = 1
|
||||
REFRESH_TOKEN_DAYS = 30
|
||||
|
||||
|
||||
def jwt_secret() -> str:
|
||||
secret = os.environ.get("MCP_JWT_SECRET", "").strip()
|
||||
if not secret:
|
||||
secret = secrets.token_urlsafe(48)
|
||||
os.environ["MCP_JWT_SECRET"] = secret
|
||||
return secret
|
||||
|
||||
|
||||
def base_url() -> str:
|
||||
return os.environ.get("MCP_BASE_URL", "https://mcp.loogle.it").rstrip("/")
|
||||
|
||||
|
||||
def scopes_for_user(user: dict, requested: str) -> str:
|
||||
parts = [s for s in requested.split() if s]
|
||||
allowed = {
|
||||
"context:read",
|
||||
"context:write",
|
||||
"knowledge:read",
|
||||
"knowledge:write",
|
||||
"gitea:read",
|
||||
"gitea:write",
|
||||
"home:read",
|
||||
"irrigation:read",
|
||||
"turni:read",
|
||||
}
|
||||
if user.get("is_admin"):
|
||||
allowed.add("admin")
|
||||
filtered = [s for s in parts if s in allowed]
|
||||
if not filtered:
|
||||
filtered = [
|
||||
"context:read",
|
||||
"context:write",
|
||||
"knowledge:read",
|
||||
"knowledge:write",
|
||||
"gitea:read",
|
||||
"gitea:write",
|
||||
"home:read",
|
||||
"irrigation:read",
|
||||
"turni:read",
|
||||
]
|
||||
return " ".join(filtered)
|
||||
|
||||
|
||||
def create_access_token(user: dict, scope: str, client_id: str) -> tuple[str, str]:
|
||||
jti = str(uuid.uuid4())
|
||||
now = datetime.datetime.utcnow()
|
||||
payload = {
|
||||
"iss": base_url(),
|
||||
"sub": user["username"],
|
||||
"uid": user["id"],
|
||||
"scope": scope,
|
||||
"client_id": client_id,
|
||||
"jti": jti,
|
||||
"iat": now,
|
||||
"exp": now + datetime.timedelta(hours=ACCESS_TOKEN_HOURS),
|
||||
}
|
||||
token = jwt.encode(payload, jwt_secret(), algorithm=JWT_ALG)
|
||||
return token, jti
|
||||
|
||||
|
||||
def create_refresh_token(user_id: int, scope: str, client_id: str) -> str:
|
||||
token = secrets.token_urlsafe(48)
|
||||
expires = (
|
||||
datetime.datetime.utcnow() + datetime.timedelta(days=REFRESH_TOKEN_DAYS)
|
||||
).strftime("%Y-%m-%d %H:%M:%S")
|
||||
get_conn().execute(
|
||||
"INSERT INTO refresh_tokens(token,user_id,client_id,scope,expires_at) VALUES (?,?,?,?,?)",
|
||||
(token, user_id, client_id, scope, expires),
|
||||
)
|
||||
get_conn().commit()
|
||||
return token
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, jwt_secret(), algorithms=[JWT_ALG], issuer=base_url())
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
jti = payload.get("jti")
|
||||
if not jti:
|
||||
return None
|
||||
row = get_conn().execute(
|
||||
"SELECT 1 FROM revoked_jtis WHERE jti=? AND expires_at > datetime('now')",
|
||||
(jti,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def revoke_jti(jti: str, expires_at: datetime.datetime) -> None:
|
||||
get_conn().execute(
|
||||
"INSERT OR IGNORE INTO revoked_jtis(jti,expires_at) VALUES (?,?)",
|
||||
(jti, expires_at.strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
get_conn().commit()
|
||||
|
||||
|
||||
def revoke_refresh_token(token: str) -> None:
|
||||
get_conn().execute("UPDATE refresh_tokens SET revoked=1 WHERE token=?", (token,))
|
||||
get_conn().commit()
|
||||
|
||||
|
||||
def consume_refresh_token(token: str) -> Optional[dict]:
|
||||
row = get_conn().execute(
|
||||
"SELECT * FROM refresh_tokens WHERE token=? AND revoked=0 AND expires_at > datetime('now')",
|
||||
(token,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
def has_scope(claims: dict, scope: str) -> bool:
|
||||
scopes = set((claims.get("scope") or "").split())
|
||||
if "admin" in scopes:
|
||||
return True
|
||||
return scope in scopes
|
||||
Whitespace-only changes.
@@ -0,0 +1,295 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""RAG su export Irrigazione + Turni (P7)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from ..db import get_conn
|
||||
from ..integrations import irrigazione, turni
|
||||
from . import embeddings, qdrant_store
|
||||
from .text_chunk import chunk_text as _chunk_text
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.apps_indexer")
|
||||
|
||||
INDEX_USER = os.environ.get("APPS_INDEX_USER", "daniele").lower()
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
flag = os.environ.get("APPS_INDEX_ENABLED", "yes").strip().lower()
|
||||
return flag not in ("0", "false", "no", "off")
|
||||
|
||||
|
||||
def _doc_id(source: str, record_id: str) -> int:
|
||||
key = f"{source}:{record_id}"
|
||||
return abs(hash(key)) % (2**31 - 1)
|
||||
|
||||
|
||||
def _index_text(source: str, record_id: str, title: str, text: str) -> dict:
|
||||
chunks = _chunk_text(_truncate(text))
|
||||
if len(chunks) > 8:
|
||||
chunks = chunks[:8]
|
||||
if not chunks:
|
||||
return {"source": source, "record_id": record_id, "chunks": 0, "skipped": True}
|
||||
vectors = embeddings.embed_texts(chunks)
|
||||
collection = qdrant_store.APPS_SHARED_COLLECTION
|
||||
doc_id = _doc_id(source, record_id)
|
||||
qdrant_store.delete_by_doc(collection, doc_id)
|
||||
ids = []
|
||||
payloads = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
point_id = f"app-{source}-{record_id}-chunk-{i}"
|
||||
ids.append(point_id)
|
||||
payloads.append(
|
||||
{
|
||||
"doc_id": doc_id,
|
||||
"source": source,
|
||||
"record_id": record_id,
|
||||
"chunk_index": i,
|
||||
"title": title,
|
||||
"text": chunk,
|
||||
"owner": "family",
|
||||
"visibility": "family",
|
||||
}
|
||||
)
|
||||
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
|
||||
get_conn().execute(
|
||||
"INSERT INTO indexed_apps_records(source,record_id,title,owner,chunk_count,indexed_at)"
|
||||
" VALUES (?,?,?,?,?,datetime('now'))"
|
||||
" ON CONFLICT(source,record_id) DO UPDATE SET"
|
||||
" title=excluded.title, chunk_count=excluded.chunk_count, indexed_at=datetime('now')",
|
||||
(source, record_id, title, "family", len(chunks)),
|
||||
)
|
||||
get_conn().commit()
|
||||
return {"source": source, "record_id": record_id, "chunks": len(chunks), "collection": collection}
|
||||
|
||||
|
||||
def _format_irrigation_history_item(item: dict, idx: int) -> str:
|
||||
parts = [f"Irrigazione storico #{idx}"]
|
||||
for key in ("started_at", "ended_at", "zone", "zone_name", "duration_min", "volume_l", "mode", "note"):
|
||||
if item.get(key) is not None:
|
||||
parts.append(f"{key}: {item[key]}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _format_irrigation_event(item: dict, idx: int) -> str:
|
||||
parts = [f"Irrigazione evento #{idx}"]
|
||||
for key in ("ts", "time", "type", "level", "message", "zone", "detail"):
|
||||
if item.get(key) is not None:
|
||||
parts.append(f"{key}: {item[key]}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _format_turni_assignment(item: dict, idx: int) -> str:
|
||||
parts = [f"Turno #{idx}"]
|
||||
for key in (
|
||||
"date", "startDate", "endDate", "doctorId", "doctorName", "doctor_name",
|
||||
"slotId", "slotName", "slot_name", "uoc", "uocName", "shiftType", "notes",
|
||||
):
|
||||
if item.get(key) is not None:
|
||||
parts.append(f"{key}: {item[key]}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = 6000) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 20] + "\n… [truncated]"
|
||||
|
||||
|
||||
def _summarize_irrigation_status(status: dict) -> str:
|
||||
lines = ["Irrigazione — snapshot stato"]
|
||||
for key in ("plan_mode", "program", "simulation", "hibernation", "draining"):
|
||||
if key in status:
|
||||
lines.append(f"{key}: {status[key]}")
|
||||
ha = status.get("ha") or {}
|
||||
lines.append(f"ha_connected: {ha.get('connected')}")
|
||||
zones = status.get("zones") or []
|
||||
lines.append(f"zone_count: {len(zones)}")
|
||||
for z in zones[:12]:
|
||||
if isinstance(z, dict):
|
||||
lines.append(
|
||||
f" - {z.get('name', z.get('id'))}: state={z.get('ha_state')} excluded={z.get('excluded')}"
|
||||
)
|
||||
analysis = status.get("analysis")
|
||||
if isinstance(analysis, dict):
|
||||
for k, v in list(analysis.items())[:8]:
|
||||
lines.append(f"analysis.{k}: {v}")
|
||||
elif analysis:
|
||||
lines.append(f"analysis: {analysis}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _summarize_zones(zones: Any) -> str:
|
||||
items = zones if isinstance(zones, list) else _normalize_list(zones)
|
||||
lines = [f"Irrigazione — zone ({len(items)})"]
|
||||
for z in items[:20]:
|
||||
if not isinstance(z, dict):
|
||||
continue
|
||||
lines.append(
|
||||
f"- {z.get('name', z.get('id'))}: ha={z.get('ha_state')} "
|
||||
f"rate_mmh={z.get('rate_mmh')} flow={z.get('zone_flow_lph')}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _normalize_list(data: Any) -> list:
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
for key in ("items", "results", "history", "events", "assignments", "records"):
|
||||
val = data.get(key)
|
||||
if isinstance(val, list):
|
||||
return val
|
||||
return []
|
||||
|
||||
|
||||
def index_irrigazione(*, username: Optional[str] = None, history_limit: Optional[int] = None, events_limit: Optional[int] = None) -> dict:
|
||||
user = username or INDEX_USER
|
||||
history_limit = history_limit if history_limit is not None else _int_env("APPS_INDEX_HISTORY_LIMIT", 25)
|
||||
events_limit = events_limit if events_limit is not None else _int_env("APPS_INDEX_EVENTS_LIMIT", 40)
|
||||
if not irrigazione.is_configured(user):
|
||||
return {"source": "irrigazione", "skipped": True, "reason": "not configured"}
|
||||
indexed = 0
|
||||
errors = 0
|
||||
try:
|
||||
status = irrigazione.get_status(user)
|
||||
status_text = _summarize_irrigation_status(status)
|
||||
_index_text("irrigazione", "status-snapshot", "Irrigazione — stato attuale", status_text)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Irrigazione status index failed: %s", exc)
|
||||
errors += 1
|
||||
try:
|
||||
zones = irrigazione.get_zones(user)
|
||||
zones_text = _summarize_zones(zones)
|
||||
_index_text("irrigazione", "zones-snapshot", "Irrigazione — zone", zones_text)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Irrigazione zones index failed: %s", exc)
|
||||
errors += 1
|
||||
try:
|
||||
history = irrigazione.get_history(user, limit=history_limit)
|
||||
for i, item in enumerate(_normalize_list(history)):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rid = str(item.get("id") or item.get("started_at") or i)
|
||||
text = _format_irrigation_history_item(item, i)
|
||||
_index_text("irrigazione", f"history-{rid}", f"Irrigazione storico {rid}", text)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Irrigazione history index failed: %s", exc)
|
||||
errors += 1
|
||||
try:
|
||||
events = irrigazione.get_events(user, limit=events_limit)
|
||||
for i, item in enumerate(_normalize_list(events)):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rid = str(item.get("id") or item.get("ts") or item.get("time") or i)
|
||||
text = _format_irrigation_event(item, i)
|
||||
_index_text("irrigazione", f"event-{rid}", f"Irrigazione evento {rid}", text)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Irrigazione events index failed: %s", exc)
|
||||
errors += 1
|
||||
try:
|
||||
lavori = irrigazione.get_lavori_summary(user)
|
||||
lavori_text = _truncate(json.dumps(lavori, ensure_ascii=False, indent=2), 4000)
|
||||
_index_text("irrigazione", "lavori-summary", "Irrigazione — lavori manutenzione", lavori_text)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Irrigazione lavori index failed: %s", exc)
|
||||
errors += 1
|
||||
return {"source": "irrigazione", "indexed": indexed, "errors": errors}
|
||||
|
||||
|
||||
def index_turni(*, username: Optional[str] = None, assignments_limit: Optional[int] = None) -> dict:
|
||||
user = username or INDEX_USER
|
||||
assignments_limit = assignments_limit if assignments_limit is not None else _int_env("APPS_INDEX_ASSIGNMENTS_LIMIT", 80)
|
||||
if not turni.is_configured(user):
|
||||
return {"source": "turni", "skipped": True, "reason": "not configured"}
|
||||
indexed = 0
|
||||
errors = 0
|
||||
try:
|
||||
status = turni.get_status()
|
||||
status_text = _truncate(json.dumps(status, ensure_ascii=False, indent=2), 2000)
|
||||
_index_text("turni", "status-snapshot", "Turni — stato servizio", status_text)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Turni status index failed: %s", exc)
|
||||
errors += 1
|
||||
try:
|
||||
doctors = turni.list_doctors(user)
|
||||
lines = ["Turni — medici"]
|
||||
for d in (_normalize_list(doctors) if not isinstance(doctors, list) else doctors)[:40]:
|
||||
if isinstance(d, dict):
|
||||
lines.append(f"- {d.get('name', d.get('fullName'))} id={d.get('id')}")
|
||||
_index_text("turni", "doctors-list", "Turni — elenco medici", "\n".join(lines))
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Turni doctors index failed: %s", exc)
|
||||
errors += 1
|
||||
try:
|
||||
assignments = turni.get_shift_assignments(user, limit=assignments_limit)
|
||||
for i, item in enumerate(_normalize_list(assignments)):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rid = str(item.get("id") or item.get("date") or i)
|
||||
text = _format_turni_assignment(item, i)
|
||||
_index_text("turni", f"assignment-{rid}", f"Turno {rid}", text)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Turni assignments index failed: %s", exc)
|
||||
errors += 1
|
||||
return {"source": "turni", "indexed": indexed, "errors": errors}
|
||||
|
||||
|
||||
def index_all(*, username: Optional[str] = None) -> dict:
|
||||
if not _enabled():
|
||||
return {"skipped": True, "reason": "APPS_INDEX_ENABLED=no"}
|
||||
user = username or INDEX_USER
|
||||
return {
|
||||
"irrigazione": index_irrigazione(username=user),
|
||||
"turni": index_turni(username=user),
|
||||
"at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def search_apps_knowledge(query: str, limit: int = 8, source: Optional[str] = None) -> list:
|
||||
vectors = embeddings.embed_texts([query])
|
||||
flt = {"source": source} if source else None
|
||||
hits = qdrant_store.search(
|
||||
[qdrant_store.APPS_SHARED_COLLECTION],
|
||||
vectors[0],
|
||||
limit=limit,
|
||||
visibility_filter=flt,
|
||||
)
|
||||
for hit in hits:
|
||||
hit.setdefault("source_type", hit.get("source", "apps"))
|
||||
return hits
|
||||
|
||||
|
||||
def list_indexed_records(source: Optional[str] = None, limit: int = 40) -> list:
|
||||
conn = get_conn()
|
||||
if source:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM indexed_apps_records WHERE source=? ORDER BY indexed_at DESC LIMIT ?",
|
||||
(source, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM indexed_apps_records ORDER BY indexed_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Embedding providers — con thermal gate e keep_alive adattivo."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from . import thermal
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.embeddings")
|
||||
|
||||
|
||||
def embed_texts(texts: list[str]) -> list[list[float]]:
|
||||
if not texts:
|
||||
return []
|
||||
ollama_url = os.environ.get("OLLAMA_URL", "").strip()
|
||||
if ollama_url:
|
||||
try:
|
||||
return _embed_ollama(texts, ollama_url)
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Ollama embedding failed: %s", exc)
|
||||
openai_key = os.environ.get("OPENAI_API_KEY", "").strip()
|
||||
if openai_key:
|
||||
return _embed_openai(texts, openai_key)
|
||||
raise RuntimeError("Nessun provider embedding configurato (OLLAMA_URL o OPENAI_API_KEY)")
|
||||
|
||||
|
||||
def _embed_ollama(texts: list[str], base_url: str) -> list[list[float]]:
|
||||
model = os.environ.get("OLLAMA_EMBED_MODEL", "nomic-embed-text")
|
||||
vectors = []
|
||||
timeout = httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
for i, text in enumerate(texts):
|
||||
status = thermal.wait_for_headroom(context=f"embed:{i+1}/{len(texts)}")
|
||||
keep_alive = thermal.suggested_keep_alive(status)
|
||||
delay = thermal.suggested_delay_s(status)
|
||||
|
||||
payload = {"model": model, "prompt": text, "keep_alive": keep_alive}
|
||||
# options.num_thread limita i thread CPU lato Ollama (se supportato)
|
||||
num_thread = os.environ.get("OLLAMA_NUM_THREAD", "").strip()
|
||||
if num_thread:
|
||||
try:
|
||||
payload["options"] = {"num_thread": int(num_thread)}
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
resp = client.post(f"{base_url.rstrip('/')}/api/embeddings", json=payload)
|
||||
if resp.status_code >= 400:
|
||||
LOGGER.warning(
|
||||
"Ollama embeddings HTTP %s: %s — payload keys=%s",
|
||||
resp.status_code,
|
||||
resp.text[:300],
|
||||
list(payload.keys()),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
vectors.append(resp.json()["embedding"])
|
||||
|
||||
if delay > 0 and i + 1 < len(texts):
|
||||
time.sleep(delay)
|
||||
|
||||
# Unload solo se esplicitamente richiesto (zona HARD) — evita spike da reload
|
||||
if keep_alive == 0:
|
||||
try:
|
||||
client.post(
|
||||
f"{base_url.rstrip('/')}/api/generate",
|
||||
json={"model": model, "keep_alive": 0},
|
||||
timeout=30.0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return vectors
|
||||
|
||||
|
||||
def _embed_openai(texts: list[str], api_key: str) -> list[list[float]]:
|
||||
model = os.environ.get("OPENAI_EMBED_MODEL", "text-embedding-3-small")
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
resp = client.post(
|
||||
"https://api.openai.com/v1/embeddings",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
json={"model": model, "input": texts},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()["data"]
|
||||
return [item["embedding"] for item in sorted(data, key=lambda x: x["index"])]
|
||||
@@ -0,0 +1,495 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Gitea REST API client — token per utente MCP."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.gitea")
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "https://git.loogle.it").rstrip("/")
|
||||
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||
GITEA_API_TOKEN_SCOPES = "read:repository,write:repository,write:issue,write:user,read:user"
|
||||
TEXT_EXTENSIONS = {
|
||||
".md", ".txt", ".py", ".sh", ".yml", ".yaml", ".json", ".toml", ".ini",
|
||||
".conf", ".js", ".ts", ".tsx", ".jsx", ".html", ".css", ".sql", ".go",
|
||||
".rs", ".env", ".service", ".timer", ".xml", ".csv",
|
||||
}
|
||||
_tokens_cache: Optional[dict[str, str]] = None
|
||||
|
||||
|
||||
def _load_tokens() -> dict[str, str]:
|
||||
global _tokens_cache
|
||||
if _tokens_cache is not None:
|
||||
return _tokens_cache
|
||||
|
||||
tokens: dict[str, str] = {}
|
||||
json_map = os.environ.get("GITEA_API_TOKENS", "").strip()
|
||||
if json_map:
|
||||
try:
|
||||
parsed = json.loads(json_map)
|
||||
if isinstance(parsed, dict):
|
||||
tokens.update({k.lower(): v for k, v in parsed.items() if v})
|
||||
except json.JSONDecodeError:
|
||||
LOGGER.warning("GITEA_API_TOKENS non è JSON valido")
|
||||
|
||||
fallback = os.environ.get("GITEA_API_TOKEN", "").strip()
|
||||
for user in MCP_USERS:
|
||||
env_key = f"GITEA_API_TOKEN_{user.upper()}"
|
||||
token = os.environ.get(env_key, "").strip()
|
||||
if token:
|
||||
tokens[user] = token
|
||||
elif user not in tokens and fallback:
|
||||
tokens[user] = fallback
|
||||
|
||||
if not tokens and fallback:
|
||||
tokens["daniele"] = fallback
|
||||
|
||||
_tokens_cache = tokens
|
||||
return tokens
|
||||
|
||||
|
||||
def list_configured_users() -> list[str]:
|
||||
return list(_load_tokens().keys())
|
||||
|
||||
|
||||
def is_configured(username: Optional[str] = None) -> bool:
|
||||
tokens = _load_tokens()
|
||||
if not tokens:
|
||||
return False
|
||||
if username:
|
||||
user = username.lower()
|
||||
return user in tokens or "daniele" in tokens or bool(tokens)
|
||||
return True
|
||||
|
||||
|
||||
def _headers(username: Optional[str] = None) -> dict[str, str]:
|
||||
tokens = _load_tokens()
|
||||
if not tokens:
|
||||
raise RuntimeError(
|
||||
"Nessun token Gitea configurato. "
|
||||
"Imposta GITEA_API_TOKEN o GITEA_API_TOKEN_{USER} in .env — vedi docs/GITEA-TOKEN.md"
|
||||
)
|
||||
user = (username or "daniele").lower()
|
||||
token = tokens.get(user) or tokens.get("daniele") or next(iter(tokens.values()))
|
||||
return {"Authorization": f"token {token}"}
|
||||
|
||||
|
||||
def parse_repo(repo: str) -> tuple[str, str]:
|
||||
cleaned = repo.strip().strip("/")
|
||||
if cleaned.count("/") != 1:
|
||||
raise ValueError("repo deve essere nel formato owner/name (es. daniele/rete)")
|
||||
owner, name = cleaned.split("/", 1)
|
||||
if not owner or not name:
|
||||
raise ValueError("repo deve essere nel formato owner/name (es. daniele/rete)")
|
||||
return owner, name
|
||||
|
||||
|
||||
def api_base_url() -> str:
|
||||
return os.environ.get("GITEA_API_URL", GITEA_URL).rstrip("/")
|
||||
|
||||
|
||||
def public_base_url() -> str:
|
||||
return os.environ.get("GITEA_URL", "https://git.loogle.it").rstrip("/")
|
||||
|
||||
|
||||
def _api_url(path: str) -> str:
|
||||
return f"{api_base_url()}/api/v1{path}"
|
||||
|
||||
|
||||
def _request(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
username: Optional[str] = None,
|
||||
params: Optional[dict] = None,
|
||||
json_body: Optional[dict] = None,
|
||||
) -> Any:
|
||||
with httpx.Client(timeout=60.0, verify=True) as client:
|
||||
resp = client.request(
|
||||
method,
|
||||
_api_url(path),
|
||||
headers=_headers(username),
|
||||
params=params,
|
||||
json=json_body,
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(resp.text or "Risorsa Gitea non trovata")
|
||||
resp.raise_for_status()
|
||||
if resp.content:
|
||||
return resp.json()
|
||||
return {}
|
||||
|
||||
|
||||
def list_repos(
|
||||
username: Optional[str] = None,
|
||||
page: int = 1,
|
||||
limit: int = 50,
|
||||
) -> dict:
|
||||
data = _request(
|
||||
"GET",
|
||||
"/user/repos",
|
||||
username=username,
|
||||
params={"page": page, "limit": limit, "sort": "updated"},
|
||||
)
|
||||
repos = []
|
||||
for repo in data if isinstance(data, list) else []:
|
||||
full_name = repo.get("full_name") or ""
|
||||
if not full_name and repo.get("owner"):
|
||||
full_name = f"{repo['owner'].get('login', '')}/{repo.get('name', '')}"
|
||||
repos.append(
|
||||
{
|
||||
"full_name": full_name,
|
||||
"description": repo.get("description") or "",
|
||||
"private": bool(repo.get("private")),
|
||||
"html_url": repo.get("html_url") or f"{public_base_url()}/{full_name}",
|
||||
"default_branch": repo.get("default_branch") or "main",
|
||||
"updated_at": repo.get("updated_at"),
|
||||
}
|
||||
)
|
||||
return {"repos": repos, "page": page, "count": len(repos)}
|
||||
|
||||
|
||||
def _decode_content(entry: dict) -> str:
|
||||
encoding = (entry.get("encoding") or "").lower()
|
||||
raw = entry.get("content") or ""
|
||||
if encoding == "base64":
|
||||
return base64.b64decode(raw).decode("utf-8", errors="replace")
|
||||
return raw
|
||||
|
||||
|
||||
def get_file(
|
||||
repo: str,
|
||||
path: str,
|
||||
ref: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
) -> dict:
|
||||
owner, name = parse_repo(repo)
|
||||
file_path = path.lstrip("/")
|
||||
params = {}
|
||||
if ref:
|
||||
params["ref"] = ref
|
||||
encoded_path = "/".join(quote(part, safe="") for part in file_path.split("/"))
|
||||
data = _request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{name}/contents/{encoded_path}",
|
||||
username=username,
|
||||
params=params or None,
|
||||
)
|
||||
if isinstance(data, list):
|
||||
entries = [
|
||||
{
|
||||
"name": item.get("name"),
|
||||
"path": item.get("path"),
|
||||
"type": item.get("type"),
|
||||
"size": item.get("size"),
|
||||
}
|
||||
for item in data
|
||||
]
|
||||
return {
|
||||
"repo": f"{owner}/{name}",
|
||||
"path": file_path or "/",
|
||||
"type": "dir",
|
||||
"entries": entries,
|
||||
}
|
||||
content = _decode_content(data)
|
||||
return {
|
||||
"repo": f"{owner}/{name}",
|
||||
"path": data.get("path") or file_path,
|
||||
"type": data.get("type") or "file",
|
||||
"size": data.get("size"),
|
||||
"sha": data.get("sha"),
|
||||
"html_url": data.get("html_url") or f"{public_base_url()}/{owner}/{name}/src/branch/{ref or 'main'}/{file_path}",
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
def search_code(
|
||||
query: str,
|
||||
repo: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
username: Optional[str] = None,
|
||||
) -> dict:
|
||||
q = query.strip()
|
||||
if not q:
|
||||
raise ValueError("query obbligatoria")
|
||||
params: dict[str, Any] = {"q": q, "limit": min(max(limit, 1), 50)}
|
||||
if repo:
|
||||
owner, name = parse_repo(repo)
|
||||
params["repo"] = f"{owner}/{name}"
|
||||
try:
|
||||
data = _request("GET", "/search/code", username=username, params=params)
|
||||
hits = []
|
||||
for item in data.get("data") or []:
|
||||
repo_name = item.get("repository", {}).get("full_name") or item.get("repository", {}).get("name")
|
||||
hits.append(
|
||||
{
|
||||
"repo": repo_name,
|
||||
"path": item.get("path"),
|
||||
"sha": item.get("sha"),
|
||||
"html_url": item.get("url") or item.get("html_url"),
|
||||
"language": item.get("language"),
|
||||
"snippet": (item.get("content") or item.get("text") or "")[:500],
|
||||
}
|
||||
)
|
||||
return {"query": q, "repo": repo, "results": hits, "count": len(hits)}
|
||||
except FileNotFoundError:
|
||||
return _search_code_fallback(q, repo, limit, username)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code not in (404, 422):
|
||||
raise
|
||||
return _search_code_fallback(q, repo, limit, username)
|
||||
|
||||
|
||||
def _search_code_fallback(
|
||||
query: str,
|
||||
repo: Optional[str],
|
||||
limit: int,
|
||||
username: Optional[str],
|
||||
) -> dict:
|
||||
"""Fallback se /search/code non disponibile: tree + grep su file testo."""
|
||||
repos: list[str] = []
|
||||
if repo:
|
||||
owner, name = parse_repo(repo)
|
||||
repos.append(f"{owner}/{name}")
|
||||
else:
|
||||
listed = list_repos(username=username, limit=20)
|
||||
repos = [r["full_name"] for r in listed["repos"] if r.get("full_name")]
|
||||
|
||||
terms = [t.lower() for t in re.split(r"\s+", query) if t]
|
||||
hits: list[dict] = []
|
||||
max_files = min(limit * 3, 40)
|
||||
|
||||
for full_name in repos:
|
||||
owner, name = parse_repo(full_name)
|
||||
try:
|
||||
tree = _request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{name}/git/trees/HEAD",
|
||||
username=username,
|
||||
params={"recursive": "1"},
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
scanned = 0
|
||||
for node in tree.get("tree") or []:
|
||||
if node.get("type") != "blob":
|
||||
continue
|
||||
path = node.get("path") or ""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext and ext not in TEXT_EXTENSIONS:
|
||||
continue
|
||||
if any(term in path.lower() for term in terms):
|
||||
pass
|
||||
scanned += 1
|
||||
if scanned > max_files:
|
||||
break
|
||||
try:
|
||||
file_data = get_file(full_name, path, username=username)
|
||||
except Exception:
|
||||
continue
|
||||
content = (file_data.get("content") or "").lower()
|
||||
if not any(term in content or term in path.lower() for term in terms):
|
||||
continue
|
||||
snippet = file_data.get("content") or ""
|
||||
idx = snippet.lower().find(terms[0]) if terms else 0
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
hits.append(
|
||||
{
|
||||
"repo": full_name,
|
||||
"path": path,
|
||||
"sha": node.get("sha"),
|
||||
"html_url": file_data.get("html_url"),
|
||||
"snippet": snippet[max(0, idx - 80): idx + 420],
|
||||
}
|
||||
)
|
||||
if len(hits) >= limit:
|
||||
break
|
||||
if len(hits) >= limit:
|
||||
break
|
||||
|
||||
return {"query": query, "repo": repo, "results": hits[:limit], "count": len(hits[:limit]), "mode": "fallback"}
|
||||
|
||||
|
||||
def list_issues(
|
||||
repo: str,
|
||||
state: str = "open",
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
username: Optional[str] = None,
|
||||
) -> dict:
|
||||
owner, name = parse_repo(repo)
|
||||
data = _request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{name}/issues",
|
||||
username=username,
|
||||
params={"state": state, "page": page, "limit": limit, "type": "issues"},
|
||||
)
|
||||
issues = []
|
||||
for item in data if isinstance(data, list) else []:
|
||||
issues.append(
|
||||
{
|
||||
"number": item.get("number"),
|
||||
"title": item.get("title"),
|
||||
"state": item.get("state"),
|
||||
"user": (item.get("user") or {}).get("login"),
|
||||
"html_url": item.get("html_url"),
|
||||
"created_at": item.get("created_at"),
|
||||
"updated_at": item.get("updated_at"),
|
||||
"labels": [lbl.get("name") for lbl in (item.get("labels") or [])],
|
||||
}
|
||||
)
|
||||
return {"repo": f"{owner}/{name}", "state": state, "issues": issues, "count": len(issues)}
|
||||
|
||||
|
||||
def get_issue(
|
||||
repo: str,
|
||||
number: int,
|
||||
username: Optional[str] = None,
|
||||
) -> dict:
|
||||
owner, name = parse_repo(repo)
|
||||
item = _request("GET", f"/repos/{owner}/{name}/issues/{number}", username=username)
|
||||
return {
|
||||
"repo": f"{owner}/{name}",
|
||||
"number": item.get("number"),
|
||||
"title": item.get("title"),
|
||||
"state": item.get("state"),
|
||||
"body": item.get("body") or "",
|
||||
"user": (item.get("user") or {}).get("login"),
|
||||
"html_url": item.get("html_url"),
|
||||
"created_at": item.get("created_at"),
|
||||
"updated_at": item.get("updated_at"),
|
||||
"labels": [lbl.get("name") for lbl in (item.get("labels") or [])],
|
||||
}
|
||||
|
||||
|
||||
def create_issue(
|
||||
repo: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
labels: Optional[list[str]] = None,
|
||||
username: Optional[str] = None,
|
||||
) -> dict:
|
||||
owner, name = parse_repo(repo)
|
||||
payload: dict[str, Any] = {"title": title.strip(), "body": body or ""}
|
||||
if labels:
|
||||
payload["labels"] = labels
|
||||
item = _request(
|
||||
"POST",
|
||||
f"/repos/{owner}/{name}/issues",
|
||||
username=username,
|
||||
json_body=payload,
|
||||
)
|
||||
return {
|
||||
"repo": f"{owner}/{name}",
|
||||
"number": item.get("number"),
|
||||
"title": item.get("title"),
|
||||
"state": item.get("state"),
|
||||
"html_url": item.get("html_url"),
|
||||
}
|
||||
|
||||
|
||||
def assert_repo_owner(username: str, repo: str, *, is_admin: bool = False) -> tuple[str, str]:
|
||||
owner, name = parse_repo(repo)
|
||||
if not is_admin and owner.lower() != username.lower():
|
||||
raise PermissionError(
|
||||
f"Puoi scrivere solo su repository di cui sei owner (repo {owner}/{name}, utente {username})"
|
||||
)
|
||||
return owner, name
|
||||
|
||||
|
||||
def create_repo(
|
||||
name: str,
|
||||
username: Optional[str] = None,
|
||||
*,
|
||||
private: bool = True,
|
||||
description: str = "",
|
||||
auto_init: bool = True,
|
||||
) -> dict:
|
||||
repo_name = name.strip().lower()
|
||||
if not repo_name or not re.match(r"^[a-z0-9][a-z0-9._-]{0,99}$", repo_name):
|
||||
raise ValueError("name repo non valido (usa lettere minuscole, numeri, -, _, .)")
|
||||
payload: dict[str, Any] = {
|
||||
"name": repo_name,
|
||||
"private": private,
|
||||
"auto_init": auto_init,
|
||||
"description": description.strip(),
|
||||
}
|
||||
item = _request("POST", "/user/repos", username=username, json_body=payload)
|
||||
full_name = item.get("full_name") or f"{username}/{repo_name}"
|
||||
return {
|
||||
"full_name": full_name,
|
||||
"private": bool(item.get("private", private)),
|
||||
"html_url": item.get("html_url") or f"{public_base_url()}/{full_name}",
|
||||
"default_branch": item.get("default_branch") or "main",
|
||||
"description": item.get("description") or description,
|
||||
}
|
||||
|
||||
|
||||
def create_or_update_file(
|
||||
repo: str,
|
||||
path: str,
|
||||
content: str,
|
||||
message: str,
|
||||
*,
|
||||
branch: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
) -> dict:
|
||||
owner, repo_name = parse_repo(repo)
|
||||
file_path = path.lstrip("/")
|
||||
if not file_path:
|
||||
raise ValueError("path obbligatorio")
|
||||
if not message.strip():
|
||||
raise ValueError("message commit obbligatorio")
|
||||
|
||||
encoded_path = "/".join(quote(part, safe="") for part in file_path.split("/"))
|
||||
params = {}
|
||||
if branch:
|
||||
params["ref"] = branch
|
||||
|
||||
sha = None
|
||||
action = "create"
|
||||
try:
|
||||
existing = get_file(repo, file_path, ref=branch, username=username)
|
||||
if existing.get("type") == "file":
|
||||
sha = existing.get("sha")
|
||||
action = "update"
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
||||
"message": message.strip(),
|
||||
}
|
||||
if sha:
|
||||
body["sha"] = sha
|
||||
if branch:
|
||||
body["branch"] = branch
|
||||
|
||||
method = "PUT" if sha else "POST"
|
||||
item = _request(
|
||||
method,
|
||||
f"/repos/{owner}/{repo_name}/contents/{encoded_path}",
|
||||
username=username,
|
||||
params=params or None,
|
||||
json_body=body,
|
||||
)
|
||||
commit = item.get("commit") or {}
|
||||
content_obj = item.get("content") or {}
|
||||
return {
|
||||
"repo": f"{owner}/{repo_name}",
|
||||
"path": file_path,
|
||||
"action": action,
|
||||
"branch": branch or "default",
|
||||
"sha": content_obj.get("sha"),
|
||||
"commit_sha": commit.get("sha"),
|
||||
"html_url": content_obj.get("html_url")
|
||||
or f"{public_base_url()}/{owner}/{repo_name}/src/branch/{branch or 'main'}/{file_path}",
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Indicizzazione semantica (RAG) su file testo dei repository Gitea."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import zlib
|
||||
from typing import Optional
|
||||
|
||||
from ..db import get_conn
|
||||
from . import embeddings, gitea, qdrant_store
|
||||
from .text_chunk import CHUNK_OVERLAP, CHUNK_SIZE, chunk_text as _chunk_text
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.gitea_indexer")
|
||||
|
||||
SKIP_PATH_PARTS = (
|
||||
"node_modules/",
|
||||
"vendor/",
|
||||
".git/",
|
||||
"dist/",
|
||||
"build/",
|
||||
"__pycache__/",
|
||||
".venv/",
|
||||
"venv/",
|
||||
".tox/",
|
||||
"coverage/",
|
||||
)
|
||||
PRIORITY_PREFIXES = ("docs/", "ha/", "doc/", "README")
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
flag = os.environ.get("GITEA_INDEX_ENABLED", "yes").strip().lower()
|
||||
return flag not in ("0", "false", "no", "off")
|
||||
|
||||
|
||||
def _max_files_per_repo() -> int:
|
||||
return int(os.environ.get("GITEA_INDEX_MAX_FILES_PER_REPO", "150"))
|
||||
|
||||
|
||||
def _max_file_bytes() -> int:
|
||||
return int(os.environ.get("GITEA_INDEX_MAX_FILE_BYTES", "120000"))
|
||||
|
||||
|
||||
def _repos_for_user(username: str) -> list[str]:
|
||||
env_key = f"GITEA_INDEX_REPOS_{username.upper()}"
|
||||
raw = os.environ.get(env_key, "").strip()
|
||||
if raw:
|
||||
repos: list[str] = []
|
||||
for item in raw.split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
owner, name = gitea.parse_repo(item)
|
||||
repos.append(f"{owner}/{name}")
|
||||
return repos
|
||||
repos: list[str] = []
|
||||
page = 1
|
||||
while page <= 5:
|
||||
data = gitea.list_repos(username=username, page=page, limit=50)
|
||||
batch = [r["full_name"] for r in data.get("repos") or [] if r.get("full_name")]
|
||||
if not batch:
|
||||
break
|
||||
repos.extend(batch)
|
||||
if len(batch) < 50:
|
||||
break
|
||||
page += 1
|
||||
return repos
|
||||
|
||||
|
||||
def _should_index_path(path: str) -> bool:
|
||||
lowered = path.lower()
|
||||
if any(part in lowered for part in SKIP_PATH_PARTS):
|
||||
return False
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
return bool(ext and ext in gitea.TEXT_EXTENSIONS)
|
||||
|
||||
|
||||
def _path_priority(path: str) -> tuple[int, str]:
|
||||
lowered = path.lower()
|
||||
for idx, prefix in enumerate(PRIORITY_PREFIXES):
|
||||
if lowered.startswith(prefix.lower()) or os.path.basename(lowered).startswith(prefix.lower()):
|
||||
return (idx, path)
|
||||
return (len(PRIORITY_PREFIXES), path)
|
||||
|
||||
|
||||
def list_repo_text_files(repo: str, username: str) -> list[dict]:
|
||||
owner, name = gitea.parse_repo(repo)
|
||||
tree = gitea._request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{name}/git/trees/HEAD",
|
||||
username=username,
|
||||
params={"recursive": "1"},
|
||||
)
|
||||
max_files = _max_files_per_repo()
|
||||
max_bytes = _max_file_bytes()
|
||||
files: list[dict] = []
|
||||
for node in tree.get("tree") or []:
|
||||
if node.get("type") != "blob":
|
||||
continue
|
||||
path = node.get("path") or ""
|
||||
if not _should_index_path(path):
|
||||
continue
|
||||
size = int(node.get("size") or 0)
|
||||
if size > max_bytes:
|
||||
continue
|
||||
files.append({"path": path, "sha": node.get("sha"), "size": size})
|
||||
files.sort(key=lambda item: _path_priority(item["path"]))
|
||||
return files[:max_files]
|
||||
|
||||
|
||||
def _repo_owner(repo: str) -> str:
|
||||
owner, _ = gitea.parse_repo(repo)
|
||||
return owner.lower()
|
||||
|
||||
|
||||
def _repo_visibility(private: bool, owner: str) -> str:
|
||||
if private:
|
||||
return "personal"
|
||||
if owner in gitea.MCP_USERS:
|
||||
return "family"
|
||||
return "family"
|
||||
|
||||
|
||||
def _collection_for_repo(private: bool, owner: str) -> str:
|
||||
visibility = _repo_visibility(private, owner)
|
||||
if visibility == "personal":
|
||||
return qdrant_store.gitea_collection(owner)
|
||||
return qdrant_store.GITEA_SHARED_COLLECTION
|
||||
|
||||
|
||||
def _file_doc_id(repo: str, path: str) -> int:
|
||||
return zlib.adler32(f"{repo}:{path}".encode("utf-8")) & 0x7FFFFFFF
|
||||
|
||||
|
||||
def _file_title(repo: str, path: str) -> str:
|
||||
return f"{repo}/{path}"
|
||||
|
||||
|
||||
def index_file(
|
||||
repo: str,
|
||||
path: str,
|
||||
*,
|
||||
username: str,
|
||||
private: bool,
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
owner = _repo_owner(repo)
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT sha, chunk_count FROM indexed_gitea_files WHERE repo=? AND path=?",
|
||||
(repo, path),
|
||||
).fetchone()
|
||||
|
||||
file_data = gitea.get_file(repo, path, username=username)
|
||||
sha = file_data.get("sha") or ""
|
||||
if row and row["sha"] == sha and not force:
|
||||
return {"repo": repo, "path": path, "skipped": True, "sha": sha}
|
||||
|
||||
text = (file_data.get("content") or "").strip()
|
||||
if not text:
|
||||
return {"repo": repo, "path": path, "chunks": 0, "sha": sha}
|
||||
|
||||
header = f"# {_file_title(repo, path)}\n\nSource: gitea:{repo}:{path}\n\n"
|
||||
chunks = _chunk_text(header + text)
|
||||
if not chunks:
|
||||
return {"repo": repo, "path": path, "chunks": 0, "sha": sha}
|
||||
|
||||
visibility = _repo_visibility(private, owner)
|
||||
collection = _collection_for_repo(private, owner)
|
||||
doc_id = _file_doc_id(repo, path)
|
||||
qdrant_store.delete_by_doc(collection, doc_id)
|
||||
|
||||
vectors = embeddings.embed_texts(chunks)
|
||||
ids = []
|
||||
payloads = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
point_id = f"gitea-{repo}-{path}-chunk-{i}"
|
||||
ids.append(point_id)
|
||||
payloads.append(
|
||||
{
|
||||
"source": "gitea",
|
||||
"doc_id": doc_id,
|
||||
"repo": repo,
|
||||
"path": path,
|
||||
"chunk_index": i,
|
||||
"title": _file_title(repo, path),
|
||||
"text": chunk,
|
||||
"owner": owner,
|
||||
"visibility": visibility,
|
||||
"sha": sha,
|
||||
}
|
||||
)
|
||||
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
|
||||
|
||||
stored = qdrant_store.count_by_doc(collection, doc_id)
|
||||
if stored < len(chunks):
|
||||
raise RuntimeError(
|
||||
f"Qdrant upsert incompleto per {repo}/{path}: attesi {len(chunks)} chunk, trovati {stored}"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO indexed_gitea_files(repo,path,sha,owner,visibility,chunk_count,indexed_at)"
|
||||
" VALUES (?,?,?,?,?,?,datetime('now'))"
|
||||
" ON CONFLICT(repo,path) DO UPDATE SET"
|
||||
" sha=excluded.sha, owner=excluded.owner, visibility=excluded.visibility,"
|
||||
" chunk_count=excluded.chunk_count, indexed_at=datetime('now')",
|
||||
(repo, path, sha, owner, visibility, len(chunks)),
|
||||
)
|
||||
conn.commit()
|
||||
return {
|
||||
"repo": repo,
|
||||
"path": path,
|
||||
"sha": sha,
|
||||
"chunks": len(chunks),
|
||||
"collection": collection,
|
||||
"visibility": visibility,
|
||||
}
|
||||
|
||||
|
||||
def index_repo(
|
||||
repo: str,
|
||||
*,
|
||||
username: Optional[str] = None,
|
||||
private: Optional[bool] = None,
|
||||
force: bool = False,
|
||||
max_files: Optional[int] = None,
|
||||
) -> dict:
|
||||
gitea_user = username or _repo_owner(repo)
|
||||
if not gitea.is_configured(gitea_user):
|
||||
raise RuntimeError(f"Gitea non configurato per {gitea_user}")
|
||||
|
||||
if private is None:
|
||||
owner, name = gitea.parse_repo(repo)
|
||||
meta = gitea._request("GET", f"/repos/{owner}/{name}", username=gitea_user)
|
||||
private = bool(meta.get("private"))
|
||||
|
||||
files = list_repo_text_files(repo, gitea_user)
|
||||
if max_files is not None:
|
||||
files = files[: max(1, max_files)]
|
||||
|
||||
indexed = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
chunks = 0
|
||||
for item in files:
|
||||
try:
|
||||
result = index_file(
|
||||
repo,
|
||||
item["path"],
|
||||
username=gitea_user,
|
||||
private=bool(private),
|
||||
force=force,
|
||||
)
|
||||
if result.get("skipped"):
|
||||
skipped += 1
|
||||
else:
|
||||
indexed += 1
|
||||
chunks += int(result.get("chunks") or 0)
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Index gitea %s/%s failed: %s", repo, item.get("path"), exc)
|
||||
errors += 1
|
||||
|
||||
return {
|
||||
"repo": repo,
|
||||
"files_seen": len(files),
|
||||
"files_indexed": indexed,
|
||||
"files_skipped": skipped,
|
||||
"errors": errors,
|
||||
"chunks": chunks,
|
||||
"private": bool(private),
|
||||
}
|
||||
|
||||
|
||||
def index_all(max_files_per_repo: Optional[int] = None) -> dict:
|
||||
if not _enabled():
|
||||
return {"enabled": False, "indexed_files": 0}
|
||||
|
||||
total_indexed = 0
|
||||
total_skipped = 0
|
||||
total_errors = 0
|
||||
total_chunks = 0
|
||||
repos_done: list[str] = []
|
||||
users = gitea.list_configured_users() or []
|
||||
|
||||
for username in users:
|
||||
try:
|
||||
repos = _repos_for_user(username)
|
||||
except Exception as exc:
|
||||
LOGGER.error("Lista repo Gitea fallita per %s: %s", username, exc)
|
||||
continue
|
||||
for repo in repos:
|
||||
try:
|
||||
result = index_repo(
|
||||
repo,
|
||||
username=username,
|
||||
force=False,
|
||||
max_files=max_files_per_repo,
|
||||
)
|
||||
repos_done.append(repo)
|
||||
total_indexed += int(result.get("files_indexed") or 0)
|
||||
total_skipped += int(result.get("files_skipped") or 0)
|
||||
total_errors += int(result.get("errors") or 0)
|
||||
total_chunks += int(result.get("chunks") or 0)
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Index repo %s failed (%s): %s", repo, username, exc)
|
||||
total_errors += 1
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"repos": repos_done,
|
||||
"files_indexed": total_indexed,
|
||||
"files_skipped": total_skipped,
|
||||
"errors": total_errors,
|
||||
"chunks": total_chunks,
|
||||
"users": users,
|
||||
}
|
||||
|
||||
|
||||
def gitea_collections_for_user(username: str, is_admin: bool = False) -> list[str]:
|
||||
cols = [qdrant_store.GITEA_SHARED_COLLECTION, qdrant_store.gitea_collection(username)]
|
||||
if is_admin:
|
||||
for user in gitea.MCP_USERS:
|
||||
cols.append(qdrant_store.gitea_collection(user))
|
||||
return list(dict.fromkeys(cols))
|
||||
|
||||
|
||||
def search_gitea_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list:
|
||||
vectors = embeddings.embed_texts([query])
|
||||
collections = gitea_collections_for_user(username, is_admin=is_admin)
|
||||
hits = qdrant_store.search(collections, vectors[0], limit=limit)
|
||||
for hit in hits:
|
||||
hit["source"] = hit.get("source") or "gitea"
|
||||
return hits
|
||||
|
||||
|
||||
def list_indexed_files(limit: int = 30, repo: Optional[str] = None) -> list:
|
||||
conn = get_conn()
|
||||
if repo:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM indexed_gitea_files WHERE repo=? ORDER BY indexed_at DESC LIMIT ?",
|
||||
(repo, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM indexed_gitea_files ORDER BY indexed_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def index_stats() -> dict:
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*), COALESCE(SUM(chunk_count), 0) FROM indexed_gitea_files"
|
||||
).fetchone()
|
||||
files_count = int(row[0] if row else 0)
|
||||
chunks_meta = int(row[1] if row else 0)
|
||||
collections = {qdrant_store.GITEA_SHARED_COLLECTION}
|
||||
for user in gitea.MCP_USERS:
|
||||
collections.add(qdrant_store.gitea_collection(user))
|
||||
qdrant_points = sum(qdrant_store.collection_point_count(c) for c in collections)
|
||||
return {
|
||||
"files_indexed": files_count,
|
||||
"chunks_in_metadata": chunks_meta,
|
||||
"qdrant_points": qdrant_points,
|
||||
"collections": {c: qdrant_store.collection_point_count(c) for c in sorted(collections)},
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Paperless → Qdrant indexing pipeline."""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.indexer")
|
||||
|
||||
from ..db import get_conn
|
||||
from . import embeddings, gitea_indexer, paperless, qdrant_store
|
||||
from .text_chunk import chunk_text as _chunk_text
|
||||
|
||||
|
||||
def index_document(doc_id: int, force: bool = False, paperless_user: Optional[str] = None) -> dict:
|
||||
conn = get_conn()
|
||||
existing = conn.execute(
|
||||
"SELECT doc_id FROM indexed_documents WHERE doc_id=?", (doc_id,)
|
||||
).fetchone()
|
||||
if existing and not force:
|
||||
return {"doc_id": doc_id, "skipped": True}
|
||||
doc = paperless.get_document(doc_id, username=paperless_user)
|
||||
text = paperless.download_document_text(doc_id, username=paperless_user)
|
||||
owner = paperless.document_owner(doc)
|
||||
visibility = paperless.document_visibility(doc, owner)
|
||||
chunks = _chunk_text(text)
|
||||
if not chunks:
|
||||
return {"doc_id": doc_id, "chunks": 0}
|
||||
vectors = embeddings.embed_texts(chunks)
|
||||
collection = qdrant_store.SHARED_COLLECTION if visibility == "family" else qdrant_store.kb_collection(owner)
|
||||
qdrant_store.delete_by_doc(collection, doc_id)
|
||||
ids = []
|
||||
payloads = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
point_id = f"doc-{doc_id}-chunk-{i}"
|
||||
ids.append(point_id)
|
||||
payloads.append(
|
||||
{
|
||||
"doc_id": doc_id,
|
||||
"chunk_index": i,
|
||||
"title": doc.get("title") or f"Documento {doc_id}",
|
||||
"text": chunk,
|
||||
"owner": owner,
|
||||
"visibility": visibility,
|
||||
}
|
||||
)
|
||||
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
|
||||
conn.execute(
|
||||
"INSERT INTO indexed_documents(doc_id,title,owner,visibility,chunk_count,indexed_at)"
|
||||
" VALUES (?,?,?,?,?,datetime('now'))"
|
||||
" ON CONFLICT(doc_id) DO UPDATE SET"
|
||||
" title=excluded.title, owner=excluded.owner, visibility=excluded.visibility,"
|
||||
" chunk_count=excluded.chunk_count, indexed_at=datetime('now')",
|
||||
(doc_id, doc.get("title"), owner, visibility, len(chunks)),
|
||||
)
|
||||
conn.commit()
|
||||
return {"doc_id": doc_id, "chunks": len(chunks), "collection": collection}
|
||||
|
||||
|
||||
def index_all(max_pages: int = 20) -> dict:
|
||||
indexed = 0
|
||||
errors = 0
|
||||
seen: set[int] = set()
|
||||
users = paperless.list_configured_users() or ["daniele"]
|
||||
for paperless_user in users:
|
||||
page = 1
|
||||
while page <= max_pages:
|
||||
try:
|
||||
data = paperless.list_documents(page=page, page_size=25, username=paperless_user)
|
||||
except Exception as exc:
|
||||
LOGGER.error("Paperless list failed for %s: %s", paperless_user, exc)
|
||||
break
|
||||
results = data.get("results") or []
|
||||
if not results:
|
||||
break
|
||||
for doc in results:
|
||||
doc_id = doc["id"]
|
||||
if doc_id in seen:
|
||||
continue
|
||||
seen.add(doc_id)
|
||||
try:
|
||||
index_document(doc_id, paperless_user=paperless_user)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Index doc %s failed (%s): %s", doc_id, paperless_user, exc)
|
||||
errors += 1
|
||||
if not data.get("next"):
|
||||
break
|
||||
page += 1
|
||||
return {"indexed": indexed, "errors": errors, "users": users}
|
||||
|
||||
|
||||
def index_context_snippet(
|
||||
username: str,
|
||||
project_id: str,
|
||||
text: str,
|
||||
title: str,
|
||||
snippet_id: Optional[str] = None,
|
||||
) -> None:
|
||||
chunks = _chunk_text(text)
|
||||
if not chunks:
|
||||
return
|
||||
vectors = embeddings.embed_texts(chunks)
|
||||
collection = qdrant_store.ctx_collection(username)
|
||||
doc_key = f"ctx-{username}-{project_id}"
|
||||
if snippet_id:
|
||||
doc_key = f"{doc_key}-{snippet_id}"
|
||||
doc_id = hash(doc_key) % (2**31)
|
||||
qdrant_store.delete_by_doc(collection, doc_id)
|
||||
ids = []
|
||||
payloads = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
point_id = f"{doc_key}-chunk-{i}"
|
||||
ids.append(point_id)
|
||||
payloads.append(
|
||||
{
|
||||
"doc_id": doc_id,
|
||||
"project_id": project_id,
|
||||
"chunk_index": i,
|
||||
"title": title,
|
||||
"text": chunk,
|
||||
"owner": username,
|
||||
"visibility": "personal",
|
||||
"source": "context",
|
||||
"snippet_id": snippet_id or "latest",
|
||||
}
|
||||
)
|
||||
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
|
||||
|
||||
|
||||
def search_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list:
|
||||
vectors = embeddings.embed_texts([query])
|
||||
collections = [
|
||||
qdrant_store.SHARED_COLLECTION,
|
||||
qdrant_store.kb_collection(username),
|
||||
qdrant_store.ctx_collection(username),
|
||||
]
|
||||
collections.extend(gitea_indexer.gitea_collections_for_user(username, is_admin=is_admin))
|
||||
collections.append(qdrant_store.APPS_SHARED_COLLECTION)
|
||||
if is_admin:
|
||||
for u in ("daniele", "lucia", "davide", "luca"):
|
||||
collections.append(qdrant_store.kb_collection(u))
|
||||
collections = list(dict.fromkeys(collections))
|
||||
hits = qdrant_store.search(collections, vectors[0], limit=limit)
|
||||
for hit in hits:
|
||||
if hit.get("repo") and not hit.get("source"):
|
||||
hit["source"] = "gitea"
|
||||
elif hit.get("source") in ("irrigazione", "turni"):
|
||||
hit["source_type"] = "apps"
|
||||
elif hit.get("doc_id") and not hit.get("source"):
|
||||
hit["source"] = "paperless"
|
||||
return hits
|
||||
|
||||
|
||||
def search_gitea_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list:
|
||||
return gitea_indexer.search_gitea_knowledge(username, query, limit=limit, is_admin=is_admin)
|
||||
|
||||
|
||||
def search_context(username: str, query: str, limit: int = 8) -> list:
|
||||
vectors = embeddings.embed_texts([query])
|
||||
return qdrant_store.search([qdrant_store.ctx_collection(username)], vectors[0], limit=limit)
|
||||
|
||||
|
||||
def list_recent_documents(limit: int = 20) -> list:
|
||||
rows = get_conn().execute(
|
||||
"SELECT * FROM indexed_documents ORDER BY indexed_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -0,0 +1,140 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Paperless-ngx REST API client — supporto token per utente MCP."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.paperless")
|
||||
|
||||
PAPERLESS_URL = os.environ.get("PAPERLESS_URL", "https://docs.loogle.it").rstrip("/")
|
||||
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||
_tokens_cache: Optional[dict[str, str]] = None
|
||||
|
||||
|
||||
def _load_tokens() -> dict[str, str]:
|
||||
"""Carica token Paperless per utente MCP.
|
||||
|
||||
Priorità per ogni utente:
|
||||
1. PAPERLESS_API_TOKEN_{USERNAME} (es. PAPERLESS_API_TOKEN_LUCIA)
|
||||
2. Chiavi in PAPERLESS_API_TOKENS JSON (es. {"daniele":"...", "lucia":"..."})
|
||||
3. PAPERLESS_API_TOKEN globale (fallback per tutti, tipico account admin)
|
||||
"""
|
||||
global _tokens_cache
|
||||
if _tokens_cache is not None:
|
||||
return _tokens_cache
|
||||
|
||||
tokens: dict[str, str] = {}
|
||||
json_map = os.environ.get("PAPERLESS_API_TOKENS", "").strip()
|
||||
if json_map:
|
||||
try:
|
||||
parsed = json.loads(json_map)
|
||||
if isinstance(parsed, dict):
|
||||
tokens.update({k.lower(): v for k, v in parsed.items() if v})
|
||||
except json.JSONDecodeError:
|
||||
LOGGER.warning("PAPERLESS_API_TOKENS non è JSON valido")
|
||||
|
||||
fallback = os.environ.get("PAPERLESS_API_TOKEN", "").strip()
|
||||
for user in MCP_USERS:
|
||||
env_key = f"PAPERLESS_API_TOKEN_{user.upper()}"
|
||||
token = os.environ.get(env_key, "").strip()
|
||||
if token:
|
||||
tokens[user] = token
|
||||
elif user not in tokens and fallback:
|
||||
tokens[user] = fallback
|
||||
|
||||
if not tokens and fallback:
|
||||
tokens["daniele"] = fallback
|
||||
|
||||
_tokens_cache = tokens
|
||||
return tokens
|
||||
|
||||
|
||||
def list_configured_users() -> list[str]:
|
||||
return list(_load_tokens().keys())
|
||||
|
||||
|
||||
def _headers(username: Optional[str] = None) -> dict:
|
||||
tokens = _load_tokens()
|
||||
if not tokens:
|
||||
raise RuntimeError(
|
||||
"Nessun token Paperless configurato. "
|
||||
"Imposta PAPERLESS_API_TOKEN o PAPERLESS_API_TOKEN_{USER} in .env"
|
||||
)
|
||||
user = (username or "daniele").lower()
|
||||
token = tokens.get(user) or tokens.get("daniele") or next(iter(tokens.values()))
|
||||
return {"Authorization": f"Token {token}"}
|
||||
|
||||
|
||||
def list_documents(
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
ordering: str = "-modified",
|
||||
username: Optional[str] = None,
|
||||
) -> dict:
|
||||
with httpx.Client(timeout=60.0, verify=True) as client:
|
||||
resp = client.get(
|
||||
f"{PAPERLESS_URL}/api/documents/",
|
||||
headers=_headers(username),
|
||||
params={"page": page, "page_size": page_size, "ordering": ordering},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def get_document(doc_id: int, username: Optional[str] = None) -> dict:
|
||||
with httpx.Client(timeout=60.0, verify=True) as client:
|
||||
resp = client.get(
|
||||
f"{PAPERLESS_URL}/api/documents/{doc_id}/",
|
||||
headers=_headers(username),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def download_document_text(doc_id: int, username: Optional[str] = None) -> str:
|
||||
doc = get_document(doc_id, username=username)
|
||||
content = (doc.get("content") or "").strip()
|
||||
if content:
|
||||
return content
|
||||
title = doc.get("title") or f"Documento {doc_id}"
|
||||
return f"# {title}\n\n(Nessun testo OCR disponibile)"
|
||||
|
||||
|
||||
def document_visibility(doc: dict, owner_username: str) -> str:
|
||||
tags = doc.get("tags") or []
|
||||
tag_names = []
|
||||
for t in tags:
|
||||
if isinstance(t, dict):
|
||||
tag_names.append((t.get("name") or "").lower())
|
||||
elif isinstance(t, int):
|
||||
continue
|
||||
else:
|
||||
tag_names.append(str(t).lower())
|
||||
if any(t in ("personal", "privato", "private") for t in tag_names):
|
||||
return "personal"
|
||||
if any(t in ("admin-only", "admin") for t in tag_names):
|
||||
return "admin"
|
||||
return "family"
|
||||
|
||||
|
||||
def document_owner(doc: dict, default: str = "daniele") -> str:
|
||||
owner = doc.get("owner")
|
||||
if isinstance(owner, int):
|
||||
pass
|
||||
owner_username = doc.get("owner_username") or doc.get("owner_name")
|
||||
if isinstance(owner_username, str):
|
||||
name = owner_username.lower()
|
||||
for user in MCP_USERS:
|
||||
if user in name:
|
||||
return user
|
||||
correspondent = doc.get("correspondent")
|
||||
if isinstance(correspondent, dict):
|
||||
name = (correspondent.get("name") or "").lower()
|
||||
for user in MCP_USERS:
|
||||
if user in name:
|
||||
return user
|
||||
return default
|
||||
@@ -0,0 +1,251 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Vector store — Qdrant remoto (DS920) con fallback SQLite locale su Pi ARM."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.vector_store")
|
||||
VECTOR_SIZE = 768
|
||||
_local = sqlite3.connect(":memory:", check_same_thread=False) # placeholder
|
||||
_qdrant_client = None
|
||||
_qdrant_checked = False
|
||||
_use_fallback = False
|
||||
|
||||
|
||||
def _fallback_path() -> str:
|
||||
return os.environ.get("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
|
||||
|
||||
|
||||
def _fallback_conn() -> sqlite3.Connection:
|
||||
path = _fallback_path()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
conn = sqlite3.connect(path, timeout=30)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS vectors (
|
||||
id TEXT PRIMARY KEY,
|
||||
collection TEXT NOT NULL,
|
||||
vector TEXT NOT NULL,
|
||||
payload TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_vectors_collection ON vectors(collection)")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def _get_qdrant():
|
||||
global _qdrant_client, _qdrant_checked, _use_fallback
|
||||
if _qdrant_checked:
|
||||
return None if _use_fallback else _qdrant_client
|
||||
_qdrant_checked = True
|
||||
url = os.environ.get("QDRANT_URL", "").strip()
|
||||
if not url:
|
||||
_use_fallback = True
|
||||
LOGGER.warning("QDRANT_URL non impostato — fallback SQLite")
|
||||
return None
|
||||
try:
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
client = QdrantClient(url=url, timeout=60)
|
||||
client.get_collections()
|
||||
_qdrant_client = client
|
||||
globals()["qm"] = qm
|
||||
LOGGER.info("Qdrant connesso: %s", url)
|
||||
return client
|
||||
except Exception as exc:
|
||||
_use_fallback = True
|
||||
LOGGER.warning("Qdrant non disponibile (%s) — fallback SQLite", exc)
|
||||
return None
|
||||
|
||||
|
||||
def ensure_collection(name: str, vector_size: int = VECTOR_SIZE) -> None:
|
||||
client = _get_qdrant()
|
||||
if client is None:
|
||||
return
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
names = {c.name for c in client.get_collections().collections}
|
||||
if name in names:
|
||||
return
|
||||
client.create_collection(
|
||||
collection_name=name,
|
||||
vectors_config=qm.VectorParams(size=vector_size, distance=qm.Distance.COSINE),
|
||||
)
|
||||
|
||||
|
||||
def kb_collection(username: str) -> str:
|
||||
return f"kb_personal_{username}"
|
||||
|
||||
|
||||
def ctx_collection(username: str) -> str:
|
||||
return f"ctx_{username}"
|
||||
|
||||
|
||||
SHARED_COLLECTION = "kb_shared_family"
|
||||
GITEA_SHARED_COLLECTION = "gitea_shared_family"
|
||||
APPS_SHARED_COLLECTION = "apps_shared_family"
|
||||
|
||||
|
||||
def gitea_collection(username: str) -> str:
|
||||
return f"gitea_personal_{username}"
|
||||
|
||||
|
||||
def _point_id(name: str) -> str:
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, name))
|
||||
|
||||
|
||||
def _cosine(a: list[float], b: list[float]) -> float:
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = math.sqrt(sum(x * x for x in a)) or 1.0
|
||||
nb = math.sqrt(sum(x * x for x in b)) or 1.0
|
||||
return dot / (na * nb)
|
||||
|
||||
|
||||
def upsert_chunks(
|
||||
collection: str,
|
||||
ids: list[str],
|
||||
vectors: list[list[float]],
|
||||
payloads: list[dict],
|
||||
) -> None:
|
||||
if not vectors:
|
||||
return
|
||||
ensure_collection(collection, len(vectors[0]))
|
||||
client = _get_qdrant()
|
||||
if client is not None:
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
points = [
|
||||
qm.PointStruct(id=_point_id(pid), vector=vec, payload=payload)
|
||||
for pid, vec, payload in zip(ids, vectors, payloads)
|
||||
]
|
||||
client.upsert(collection_name=collection, points=points)
|
||||
return
|
||||
conn = _fallback_conn()
|
||||
for pid, vec, payload in zip(ids, vectors, payloads):
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO vectors(id,collection,vector,payload) VALUES (?,?,?,?)",
|
||||
(_point_id(pid), collection, json.dumps(vec), json.dumps(payload, ensure_ascii=False)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_by_doc(collection: str, doc_id: int) -> None:
|
||||
client = _get_qdrant()
|
||||
if client is not None:
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
ensure_collection(collection)
|
||||
client.delete(
|
||||
collection_name=collection,
|
||||
points_selector=qm.FilterSelector(
|
||||
filter=qm.Filter(
|
||||
must=[qm.FieldCondition(key="doc_id", match=qm.MatchValue(value=doc_id))]
|
||||
)
|
||||
),
|
||||
)
|
||||
return
|
||||
conn = _fallback_conn()
|
||||
rows = conn.execute("SELECT id,payload FROM vectors WHERE collection=?", (collection,)).fetchall()
|
||||
for row_id, payload_raw in rows:
|
||||
payload = json.loads(payload_raw)
|
||||
if payload.get("doc_id") == doc_id:
|
||||
conn.execute("DELETE FROM vectors WHERE id=?", (row_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def count_by_doc(collection: str, doc_id: int) -> int:
|
||||
client = _get_qdrant()
|
||||
if client is not None:
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
ensure_collection(collection)
|
||||
result = client.count(
|
||||
collection_name=collection,
|
||||
count_filter=qm.Filter(
|
||||
must=[qm.FieldCondition(key="doc_id", match=qm.MatchValue(value=doc_id))]
|
||||
),
|
||||
exact=True,
|
||||
)
|
||||
return int(result.count)
|
||||
conn = _fallback_conn()
|
||||
rows = conn.execute("SELECT payload FROM vectors WHERE collection=?", (collection,)).fetchall()
|
||||
count = 0
|
||||
for (payload_raw,) in rows:
|
||||
payload = json.loads(payload_raw)
|
||||
if payload.get("doc_id") == doc_id:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def collection_point_count(collection: str) -> int:
|
||||
client = _get_qdrant()
|
||||
if client is not None:
|
||||
try:
|
||||
info = client.get_collection(collection)
|
||||
return int(info.points_count or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
conn = _fallback_conn()
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM vectors WHERE collection=?", (collection,)
|
||||
).fetchone()
|
||||
return int(row[0] if row else 0)
|
||||
|
||||
|
||||
def search(
|
||||
collections: list[str],
|
||||
vector: list[float],
|
||||
limit: int = 8,
|
||||
visibility_filter: Optional[dict] = None,
|
||||
) -> list[dict]:
|
||||
results: list[dict] = []
|
||||
client = _get_qdrant()
|
||||
if client is not None:
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
for collection in collections:
|
||||
ensure_collection(collection, len(vector))
|
||||
flt = None
|
||||
if visibility_filter:
|
||||
must = [
|
||||
qm.FieldCondition(key=k, match=qm.MatchValue(value=v))
|
||||
for k, v in visibility_filter.items()
|
||||
]
|
||||
if must:
|
||||
flt = qm.Filter(must=must)
|
||||
hits = client.search(
|
||||
collection_name=collection,
|
||||
query_vector=vector,
|
||||
limit=limit,
|
||||
query_filter=flt,
|
||||
)
|
||||
for hit in hits:
|
||||
payload = dict(hit.payload or {})
|
||||
payload["score"] = hit.score
|
||||
payload["collection"] = collection
|
||||
results.append(payload)
|
||||
else:
|
||||
conn = _fallback_conn()
|
||||
for collection in collections:
|
||||
rows = conn.execute(
|
||||
"SELECT vector,payload FROM vectors WHERE collection=?", (collection,)
|
||||
).fetchall()
|
||||
for vec_raw, payload_raw in rows:
|
||||
payload = dict(json.loads(payload_raw))
|
||||
if visibility_filter:
|
||||
if any(payload.get(k) != v for k, v in visibility_filter.items()):
|
||||
continue
|
||||
score = _cosine(vector, json.loads(vec_raw))
|
||||
payload["score"] = score
|
||||
payload["collection"] = collection
|
||||
results.append(payload)
|
||||
results.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||
return results[:limit]
|
||||
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Utility condivise per chunking testo RAG."""
|
||||
|
||||
import re
|
||||
|
||||
CHUNK_SIZE = 900
|
||||
CHUNK_OVERLAP = 150
|
||||
|
||||
|
||||
def chunk_text(text: str) -> list[str]:
|
||||
text = re.sub(r"\n{3,}", "\n\n", text.strip())
|
||||
if len(text) <= CHUNK_SIZE:
|
||||
return [text] if text else []
|
||||
chunks = []
|
||||
start = 0
|
||||
while start < len(text):
|
||||
end = min(len(text), start + CHUNK_SIZE)
|
||||
chunks.append(text[start:end])
|
||||
if end >= len(text):
|
||||
break
|
||||
start = max(end - CHUNK_OVERLAP, start + 1)
|
||||
return chunks
|
||||
@@ -0,0 +1,242 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Thermal / load gate per proteggere il DS920 durante gli embedding Ollama.
|
||||
|
||||
Legge temperatura CPU e load average dal NAS e regola pause/keep_alive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.thermal")
|
||||
|
||||
# Soft: rallenta. Hard: pausa (raro se il profilo lento tiene).
|
||||
# Profilo "lento ma regolare": anticipare soft, cap load basso.
|
||||
DEFAULT_SOFT_C = 58.0
|
||||
DEFAULT_HARD_C = 70.0
|
||||
DEFAULT_CPU_TARGET_PCT = 40.0 # load1 <= nproc * 0.40
|
||||
|
||||
|
||||
def _float_env(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.environ.get(name, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def soft_temp_c() -> float:
|
||||
return _float_env("THERMAL_TEMP_SOFT_C", DEFAULT_SOFT_C)
|
||||
|
||||
|
||||
def hard_temp_c() -> float:
|
||||
return _float_env("THERMAL_TEMP_HARD_C", DEFAULT_HARD_C)
|
||||
|
||||
|
||||
def cpu_target_pct() -> float:
|
||||
return _float_env("THERMAL_CPU_TARGET_PCT", DEFAULT_CPU_TARGET_PCT)
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
flag = os.environ.get("THERMAL_GATE_ENABLED", "yes").strip().lower()
|
||||
return flag not in ("0", "false", "no", "off")
|
||||
|
||||
|
||||
def _read_via_http() -> Optional[dict]:
|
||||
url = os.environ.get("DS920_THERMAL_URL", "").strip()
|
||||
if not url:
|
||||
# default probe se non configurato
|
||||
url = os.environ.get(
|
||||
"DS920_THERMAL_URL_DEFAULT",
|
||||
"http://192.168.128.100:9191/thermal",
|
||||
).strip()
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as client:
|
||||
resp = client.get(url)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
if isinstance(data, dict) and "cpu_temp_c" in data:
|
||||
return data
|
||||
except Exception as exc:
|
||||
LOGGER.debug("Thermal HTTP probe fallita: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _read_via_ssh() -> Optional[dict]:
|
||||
host = os.environ.get("DS920_SSH_HOST", "192.168.128.100").strip()
|
||||
user = os.environ.get("DS920_SSH_USER", "daniely").strip()
|
||||
key = os.environ.get("DS920_SSH_KEY", "").strip()
|
||||
if not host:
|
||||
return None
|
||||
remote = (
|
||||
"python3 -c \"import json,os;"
|
||||
"b='/sys/class/hwmon/hwmon0';"
|
||||
"t=[int(open(f'{b}/'+n).read())/1000 for n in sorted(os.listdir(b)) "
|
||||
"if n.startswith('temp') and n.endswith('_input')];"
|
||||
"l=os.getloadavg();"
|
||||
"print(json.dumps({'cpu_temp_c':max(t) if t else None,"
|
||||
"'load1':l[0],'load5':l[1],'nproc':os.cpu_count() or 4}))\""
|
||||
)
|
||||
cmd = [
|
||||
"ssh",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
]
|
||||
if key and os.path.isfile(key):
|
||||
cmd.extend(["-i", key])
|
||||
cmd.append(f"{user}@{host}")
|
||||
cmd.append(remote)
|
||||
try:
|
||||
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, timeout=12, text=True)
|
||||
data = json.loads(out.strip())
|
||||
if isinstance(data, dict) and data.get("cpu_temp_c") is not None:
|
||||
return data
|
||||
except Exception as exc:
|
||||
LOGGER.debug("Thermal SSH probe fallita: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def read_status() -> Optional[dict]:
|
||||
"""Ritorna {cpu_temp_c, load1, load5?, nproc} oppure None se non raggiungibile."""
|
||||
data = _read_via_http()
|
||||
if data:
|
||||
data["source"] = "http"
|
||||
return data
|
||||
data = _read_via_ssh()
|
||||
if data:
|
||||
data["source"] = "ssh"
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def load_over_target(status: dict) -> bool:
|
||||
load1 = float(status.get("load1") or 0)
|
||||
nproc = float(status.get("nproc") or 4)
|
||||
target = nproc * (cpu_target_pct() / 100.0)
|
||||
return load1 > target
|
||||
|
||||
|
||||
def suggested_keep_alive(status: Optional[dict]) -> int:
|
||||
"""Secondi keep_alive Ollama.
|
||||
|
||||
Il container resta sempre acceso: non si fa unload per throttling termico
|
||||
(evita cicli load/unload). Si scarica solo se OLLAMA_UNLOAD_ON_HARD=yes.
|
||||
"""
|
||||
cool = int(_float_env("OLLAMA_KEEP_ALIVE_COOL", 300))
|
||||
default = int(_float_env("OLLAMA_KEEP_ALIVE_DEFAULT", 120))
|
||||
if not status or status.get("cpu_temp_c") is None:
|
||||
return default
|
||||
temp = float(status["cpu_temp_c"])
|
||||
unload = os.environ.get("OLLAMA_UNLOAD_ON_HARD", "no").strip().lower()
|
||||
if temp >= hard_temp_c() and unload in ("1", "true", "yes", "on"):
|
||||
return 0
|
||||
return cool
|
||||
|
||||
|
||||
def suggested_delay_s(status: Optional[dict]) -> float:
|
||||
"""Duty-cycle lento: delay base sempre presente; cresce con temp/load."""
|
||||
base = _float_env("OLLAMA_EMBED_DELAY_S", 8.0)
|
||||
if not status or status.get("cpu_temp_c") is None:
|
||||
return max(base, 5.0)
|
||||
temp = float(status["cpu_temp_c"])
|
||||
hard = hard_temp_c()
|
||||
soft = soft_temp_c()
|
||||
if temp >= hard:
|
||||
return max(base, 45.0)
|
||||
if temp >= soft:
|
||||
# soft→hard: ~base*1.5 … ~35s (continuo, non on/off)
|
||||
ratio = (temp - soft) / max(hard - soft, 1.0)
|
||||
return max(base, base * 1.5 + ratio * 25.0)
|
||||
if load_over_target(status):
|
||||
return max(base, base * 2.0)
|
||||
if temp >= soft - 4:
|
||||
return max(base, base * 1.25)
|
||||
return base
|
||||
|
||||
|
||||
def wait_for_headroom(*, context: str = "embed") -> Optional[dict]:
|
||||
"""Attende headroom: HARD = pausa lunga; altrimenti delay proporzionale.
|
||||
|
||||
Obiettivo: ritmo lento e regolare, evitando oscillazioni start/stop.
|
||||
"""
|
||||
if not enabled():
|
||||
return None
|
||||
|
||||
poll = _float_env("THERMAL_POLL_S", 30.0)
|
||||
hard = hard_temp_c()
|
||||
soft = soft_temp_c()
|
||||
# Riprendi solo quando sotto soft - 2°C (isteresi anti-oscillazione)
|
||||
resume_below = soft - _float_env("THERMAL_RESUME_MARGIN_C", 2.0)
|
||||
|
||||
while True:
|
||||
status = read_status()
|
||||
if status is None:
|
||||
LOGGER.warning("Thermal gate: probe non disponibile — delay conservativo")
|
||||
time.sleep(max(suggested_delay_s(None), 5.0))
|
||||
return None
|
||||
|
||||
temp = float(status.get("cpu_temp_c") or 0)
|
||||
load1 = float(status.get("load1") or 0)
|
||||
|
||||
if temp >= hard:
|
||||
LOGGER.warning(
|
||||
"Thermal gate [%s]: PAUSA HARD temp=%.1f°C (tetto=%.0f°C resume<=%.0f°C "
|
||||
"load1=%.2f) — riprovo tra %.0fs",
|
||||
context,
|
||||
temp,
|
||||
hard,
|
||||
resume_below,
|
||||
load1,
|
||||
poll,
|
||||
)
|
||||
time.sleep(poll)
|
||||
# Isteresi: resta in pausa finché non scende sotto soft
|
||||
while True:
|
||||
cooled = read_status()
|
||||
if cooled is None:
|
||||
time.sleep(poll)
|
||||
continue
|
||||
t2 = float(cooled.get("cpu_temp_c") or 0)
|
||||
if t2 <= resume_below and not load_over_target(cooled):
|
||||
LOGGER.info(
|
||||
"Thermal gate [%s]: ripresa dopo HARD (temp=%.1f°C)",
|
||||
context,
|
||||
t2,
|
||||
)
|
||||
status = cooled
|
||||
break
|
||||
time.sleep(poll)
|
||||
# dopo ripresa applica comunque un delay soft prima dell'embed
|
||||
time.sleep(suggested_delay_s(status))
|
||||
return status
|
||||
|
||||
if temp >= soft or load_over_target(status):
|
||||
delay = suggested_delay_s(status)
|
||||
LOGGER.info(
|
||||
"Thermal gate [%s]: rallento temp=%.1f°C load1=%.2f — delay %.1fs (source=%s)",
|
||||
context,
|
||||
temp,
|
||||
load1,
|
||||
delay,
|
||||
status.get("source"),
|
||||
)
|
||||
time.sleep(delay)
|
||||
again = read_status()
|
||||
if again and float(again.get("cpu_temp_c") or 0) >= hard:
|
||||
status = again
|
||||
continue
|
||||
return again or status
|
||||
|
||||
# Zona fredda: piccolo delay fisso per duty-cycle regolare
|
||||
cool_delay = _float_env("OLLAMA_EMBED_COOL_DELAY_S", 0.0)
|
||||
if cool_delay > 0:
|
||||
time.sleep(cool_delay)
|
||||
return status
|
||||
@@ -0,0 +1,368 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Loogle MCP Hub — gateway FastAPI + OAuth + MCP Streamable HTTP."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import audit, auth, jwt_utils, oauth
|
||||
from .db import get_conn, init_db
|
||||
from .mcp import server as mcp_server
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
LOGGER = logging.getLogger("loogle_mcp.main")
|
||||
|
||||
app = FastAPI(title="Loogle MCP Hub", docs_url=None, redoc_url=None)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://claude.ai",
|
||||
"https://chatgpt.com",
|
||||
"https://chat.openai.com",
|
||||
],
|
||||
allow_methods=["GET", "POST", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
init_db()
|
||||
auth.ensure_sessions_table()
|
||||
auth.ensure_family_users()
|
||||
oauth.ensure_default_client()
|
||||
if not os.environ.get("MCP_JWT_SECRET", "").strip():
|
||||
secret = secrets.token_urlsafe(48)
|
||||
os.environ["MCP_JWT_SECRET"] = secret
|
||||
LOGGER.warning("MCP_JWT_SECRET generato — salvalo in .env: %s", secret)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Health
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "service": "loogle-mcp", "port": int(os.environ.get("MCP_PORT", "8700"))}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ OAuth metadata
|
||||
|
||||
@app.get("/.well-known/oauth-authorization-server")
|
||||
def oauth_metadata():
|
||||
return oauth.authorization_server_metadata()
|
||||
|
||||
|
||||
@app.get("/.well-known/oauth-protected-resource")
|
||||
def protected_resource():
|
||||
return oauth.protected_resource_metadata()
|
||||
|
||||
|
||||
@app.get("/.well-known/oauth-protected-resource/mcp")
|
||||
def protected_resource_mcp():
|
||||
return oauth.protected_resource_metadata()
|
||||
|
||||
|
||||
@app.get("/.well-known/openid-configuration")
|
||||
def openid_configuration():
|
||||
"""Fallback discovery usato da Claude se oauth-authorization-server non basta."""
|
||||
return oauth.authorization_server_metadata()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ OAuth endpoints
|
||||
|
||||
class RegisterBody(BaseModel):
|
||||
client_name: str
|
||||
redirect_uris: list[str]
|
||||
|
||||
|
||||
@app.post("/oauth/register")
|
||||
def oauth_register(body: RegisterBody):
|
||||
try:
|
||||
return oauth.register_client(body.client_name, body.redirect_uris)
|
||||
except HTTPException as exc:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"error": "invalid_client_metadata", "error_description": str(exc.detail)},
|
||||
)
|
||||
|
||||
|
||||
# Alias root-level OAuth (Claude/ChatGPT fallback se la discovery RFC 8414 fallisce)
|
||||
@app.post("/register")
|
||||
def oauth_register_root(body: RegisterBody):
|
||||
return oauth_register(body)
|
||||
|
||||
|
||||
@app.get("/oauth/authorize")
|
||||
def oauth_authorize_get(
|
||||
response_type: str,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str = "context:read context:write knowledge:read knowledge:write gitea:read gitea:write",
|
||||
state: str = "",
|
||||
code_challenge: Optional[str] = None,
|
||||
code_challenge_method: Optional[str] = None,
|
||||
):
|
||||
if response_type != "code":
|
||||
raise HTTPException(400, "response_type must be code")
|
||||
html = _login_form(client_id, redirect_uri, scope, state, code_challenge, code_challenge_method)
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.post("/oauth/authorize")
|
||||
def oauth_authorize_post(
|
||||
request: Request,
|
||||
client_id: str = Form(...),
|
||||
redirect_uri: str = Form(...),
|
||||
scope: str = Form("context:read context:write knowledge:read knowledge:write gitea:read gitea:write"),
|
||||
state: str = Form(""),
|
||||
code_challenge: Optional[str] = Form(None),
|
||||
code_challenge_method: Optional[str] = Form(None),
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
ip = request.client.host if request.client else "?"
|
||||
auth.throttle(ip)
|
||||
user = auth.authenticate(username, password)
|
||||
if not user:
|
||||
auth.record_attempt(ip)
|
||||
html = _login_form(
|
||||
client_id, redirect_uri, scope, state, code_challenge, code_challenge_method,
|
||||
error="Credenziali non valide",
|
||||
)
|
||||
return HTMLResponse(html, status_code=401)
|
||||
url = oauth.build_authorize_redirect(
|
||||
client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, user["id"]
|
||||
)
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
@app.get("/authorize")
|
||||
def oauth_authorize_get_root(
|
||||
response_type: str,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str = "context:read context:write knowledge:read knowledge:write gitea:read gitea:write",
|
||||
state: str = "",
|
||||
code_challenge: Optional[str] = None,
|
||||
code_challenge_method: Optional[str] = None,
|
||||
):
|
||||
return oauth_authorize_get(
|
||||
response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method
|
||||
)
|
||||
|
||||
|
||||
@app.post("/authorize")
|
||||
def oauth_authorize_post_root(
|
||||
request: Request,
|
||||
client_id: str = Form(...),
|
||||
redirect_uri: str = Form(...),
|
||||
scope: str = Form("context:read context:write knowledge:read knowledge:write gitea:read gitea:write"),
|
||||
state: str = Form(""),
|
||||
code_challenge: Optional[str] = Form(None),
|
||||
code_challenge_method: Optional[str] = Form(None),
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
return oauth_authorize_post(
|
||||
request, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, username, password
|
||||
)
|
||||
|
||||
|
||||
@app.post("/oauth/token")
|
||||
async def oauth_token(request: Request):
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
body = await request.json()
|
||||
else:
|
||||
form = await request.form()
|
||||
body = dict(form)
|
||||
grant_type = body.get("grant_type")
|
||||
client_id = body.get("client_id") or os.environ.get("MCP_OAUTH_CLIENT_ID", "loogle-mcp-public")
|
||||
if grant_type == "authorization_code":
|
||||
return oauth.exchange_code(
|
||||
body.get("code", ""),
|
||||
client_id,
|
||||
body.get("redirect_uri", ""),
|
||||
body.get("code_verifier"),
|
||||
)
|
||||
if grant_type == "refresh_token":
|
||||
return oauth.refresh_access_token(body.get("refresh_token", ""), client_id)
|
||||
raise HTTPException(400, "grant_type non supportato")
|
||||
|
||||
|
||||
@app.post("/token")
|
||||
async def oauth_token_root(request: Request):
|
||||
return await oauth_token(request)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ MCP endpoint
|
||||
|
||||
@app.post("/mcp")
|
||||
async def mcp_post(request: Request):
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
claims = oauth.bearer_claims_from_header(auth_header)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "JSON non valido")
|
||||
if isinstance(payload, list):
|
||||
responses = mcp_server.handle_batch(payload, claims)
|
||||
return JSONResponse(responses)
|
||||
response = mcp_server.handle_message(payload, claims)
|
||||
if not claims and payload.get("method") not in ("initialize", "notifications/initialized", "ping"):
|
||||
return JSONResponse(response, status_code=401, headers=_auth_challenge_headers())
|
||||
return JSONResponse(response)
|
||||
|
||||
|
||||
@app.get("/mcp")
|
||||
def mcp_get():
|
||||
return JSONResponse(
|
||||
{"error": "Use POST for MCP JSON-RPC"},
|
||||
status_code=405,
|
||||
headers=_auth_challenge_headers(),
|
||||
)
|
||||
|
||||
|
||||
def _auth_challenge_headers() -> dict:
|
||||
base = jwt_utils.base_url()
|
||||
resource_metadata = f"{base}/.well-known/oauth-protected-resource/mcp"
|
||||
return {
|
||||
"WWW-Authenticate": f'Bearer realm="mcp", resource_metadata="{resource_metadata}"',
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Dashboard web
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class PasswordBody(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class RevokeBody(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
@app.post("/api/login")
|
||||
def api_login(body: LoginBody, request: Request, response: Response):
|
||||
ip = request.client.host if request.client else "?"
|
||||
auth.throttle(ip)
|
||||
user = auth.authenticate(body.username.strip(), body.password)
|
||||
if not user:
|
||||
auth.record_attempt(ip)
|
||||
raise HTTPException(401, "Credenziali non valide")
|
||||
import datetime
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires = (
|
||||
datetime.datetime.utcnow() + datetime.timedelta(days=auth.SESSION_DAYS)
|
||||
).strftime("%Y-%m-%d %H:%M:%S")
|
||||
get_conn().execute(
|
||||
"INSERT INTO sessions(token,user_id,expires_at) VALUES (?,?,?)",
|
||||
(token, user["id"], expires),
|
||||
)
|
||||
get_conn().commit()
|
||||
response.set_cookie("mcp_session", token, max_age=auth.SESSION_DAYS * 86400, httponly=True, samesite="lax", path="/")
|
||||
return {"ok": True, "user": {"username": user["username"], "is_admin": user["is_admin"]}}
|
||||
|
||||
|
||||
@app.post("/api/logout")
|
||||
def api_logout(request: Request, response: Response):
|
||||
token = request.cookies.get("mcp_session", "")
|
||||
if token:
|
||||
get_conn().execute("DELETE FROM sessions WHERE token=?", (token,))
|
||||
get_conn().commit()
|
||||
response.delete_cookie("mcp_session", path="/")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/me")
|
||||
def api_me(user=Depends(auth.current_user_from_cookie)):
|
||||
return user
|
||||
|
||||
|
||||
@app.post("/api/password")
|
||||
def api_password(body: PasswordBody, user=Depends(auth.current_user_from_cookie)):
|
||||
if len(body.new_password.strip()) < 6:
|
||||
raise HTTPException(400, "La nuova password deve avere almeno 6 caratteri")
|
||||
if not auth.change_password(user["id"], body.old_password, body.new_password):
|
||||
raise HTTPException(400, "Password attuale errata")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
def api_projects(user=Depends(auth.current_user_from_cookie)):
|
||||
from .context import store as context_store
|
||||
return context_store.list_projects(user["username"])
|
||||
|
||||
|
||||
@app.get("/api/audit")
|
||||
def api_audit(limit: int = 100, user=Depends(auth.current_user_from_cookie)):
|
||||
if user["is_admin"]:
|
||||
return audit.list_audit(limit=min(limit, 500))
|
||||
return audit.list_audit(limit=min(limit, 200), username=user["username"])
|
||||
|
||||
|
||||
@app.post("/api/admin/revoke-refresh")
|
||||
def api_revoke_refresh(body: RevokeBody, _=Depends(auth.require_admin)):
|
||||
jwt_utils.revoke_refresh_token(body.refresh_token)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
def dashboard_page():
|
||||
path = os.path.join(STATIC_DIR, "dashboard.html")
|
||||
return HTMLResponse(open(path, encoding="utf-8").read())
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return RedirectResponse("/dashboard")
|
||||
|
||||
|
||||
def _login_form(
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str,
|
||||
state: str,
|
||||
code_challenge: Optional[str],
|
||||
code_challenge_method: Optional[str],
|
||||
error: str = "",
|
||||
) -> str:
|
||||
err = f'<p class="error">{error}</p>' if error else ""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Loogle MCP — Login</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;max-width:420px;margin:4rem auto;padding:1rem;background:#0f172a;color:#e2e8f0}}
|
||||
h1{{font-size:1.4rem}} .card{{background:#1e293b;padding:1.5rem;border-radius:12px}}
|
||||
label{{display:block;margin:.75rem 0 .25rem}} input{{width:100%;padding:.5rem;border-radius:6px;border:1px solid #334155;background:#0f172a;color:#e2e8f0}}
|
||||
button{{margin-top:1rem;width:100%;padding:.65rem;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer}}
|
||||
.error{{color:#f87171}} .hint{{font-size:.85rem;color:#94a3b8;margin-top:1rem}}
|
||||
</style></head><body>
|
||||
<h1>Loogle MCP Hub</h1>
|
||||
<p>Accedi con le credenziali famiglia per collegare Claude, ChatGPT o Gemini.</p>
|
||||
<div class="card">{err}
|
||||
<form method="post" action="/authorize">
|
||||
<input type="hidden" name="client_id" value="{client_id}">
|
||||
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
|
||||
<input type="hidden" name="scope" value="{scope}">
|
||||
<input type="hidden" name="state" value="{state}">
|
||||
<input type="hidden" name="code_challenge" value="{code_challenge or ''}">
|
||||
<input type="hidden" name="code_challenge_method" value="{code_challenge_method or ''}">
|
||||
<label>Utente</label><input name="username" autocomplete="username" required>
|
||||
<label>Password</label><input name="password" type="password" autocomplete="current-password" required>
|
||||
<button type="submit">Autorizza accesso MCP</button>
|
||||
</form>
|
||||
<p class="hint">Primo accesso: password = username (es. lucia/lucia). Cambiala dal dashboard.</p>
|
||||
</div></body></html>"""
|
||||
Whitespace-only changes.
@@ -0,0 +1,78 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""MCP JSON-RPC handler (Streamable HTTP compatible)."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from . import tools
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.server")
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
|
||||
|
||||
def _error(req_id: Any, code: int, message: str) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
def _result(req_id: Any, result: dict) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
||||
|
||||
|
||||
def handle_message(body: dict, claims: Optional[dict]) -> dict:
|
||||
method = body.get("method")
|
||||
req_id = body.get("id")
|
||||
params = body.get("params") or {}
|
||||
|
||||
if method == "initialize":
|
||||
return _result(
|
||||
req_id,
|
||||
{
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {}, "resources": {}},
|
||||
"serverInfo": {"name": "loogle-mcp", "version": "1.0.0"},
|
||||
},
|
||||
)
|
||||
|
||||
if method == "notifications/initialized":
|
||||
return _result(req_id, {})
|
||||
|
||||
if method == "ping":
|
||||
return _result(req_id, {})
|
||||
|
||||
if not claims:
|
||||
return _error(req_id, -32001, "Autenticazione richiesta (Bearer token OAuth)")
|
||||
|
||||
if method == "tools/list":
|
||||
return _result(req_id, {"tools": tools.tool_definitions()})
|
||||
|
||||
if method == "tools/call":
|
||||
name = params.get("name")
|
||||
arguments = params.get("arguments") or {}
|
||||
try:
|
||||
tool_result = tools.call_tool(name, arguments, claims)
|
||||
return _result(req_id, tool_result)
|
||||
except PermissionError as exc:
|
||||
return _error(req_id, -32003, str(exc))
|
||||
except FileNotFoundError as exc:
|
||||
return _error(req_id, -32004, str(exc))
|
||||
except Exception as exc:
|
||||
LOGGER.exception("Tool %s failed", name)
|
||||
return _error(req_id, -32000, str(exc))
|
||||
|
||||
if method == "resources/list":
|
||||
return _result(req_id, {"resources": tools.list_resources(claims)})
|
||||
|
||||
if method == "resources/read":
|
||||
uri = params.get("uri")
|
||||
try:
|
||||
resource = tools.read_resource(uri, claims)
|
||||
return _result(req_id, {"contents": [resource]})
|
||||
except FileNotFoundError as exc:
|
||||
return _error(req_id, -32004, str(exc))
|
||||
|
||||
return _error(req_id, -32601, f"Metodo non supportato: {method}")
|
||||
|
||||
|
||||
def handle_batch(messages: list, claims: Optional[dict]) -> list:
|
||||
return [handle_message(msg, claims) for msg in messages if isinstance(msg, dict)]
|
||||
@@ -0,0 +1,962 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""MCP tool definitions and handlers."""
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from .. import audit
|
||||
from ..context import store as context_store
|
||||
from ..jwt_utils import has_scope
|
||||
from ..knowledge import apps_indexer, gitea, gitea_indexer, indexer, paperless
|
||||
from ..integrations import casa, homeassistant, irrigazione, turni
|
||||
|
||||
|
||||
def tool_definitions() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"name": "ping",
|
||||
"description": "Verifica che il server MCP Loogle risponda",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "whoami",
|
||||
"description": "Restituisce l'utente autenticato e gli scope attivi",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "list_projects",
|
||||
"description": "Elenca i progetti dell'utente corrente",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"include_archived": {"type": "boolean", "default": False}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "create_project",
|
||||
"description": "Crea un nuovo progetto con archivio contesto; opzionalmente collega un repo Gitea",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["title"],
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}},
|
||||
"gitea_repo": {
|
||||
"type": "string",
|
||||
"description": "Repository Gitea owner/name (es. daniele/rete)",
|
||||
},
|
||||
"seed_from_gitea": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Importa README.md nel context.md se collegato a Gitea",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "link_project_repo",
|
||||
"description": "Collega o scollega un repository Gitea da un progetto esistente",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["project_id"],
|
||||
"properties": {
|
||||
"project_id": {"type": "string"},
|
||||
"gitea_repo": {
|
||||
"type": "string",
|
||||
"description": "owner/name da collegare; omit o stringa vuota per scollegare",
|
||||
},
|
||||
"seed_from_gitea": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Importa README.md nel context.md (solo se non già importato)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_project_context",
|
||||
"description": "Recupera meta, context.md, sessioni recenti e arricchimento Gitea del progetto",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["project_id"],
|
||||
"properties": {
|
||||
"project_id": {"type": "string"},
|
||||
"session_limit": {"type": "integer", "default": 5},
|
||||
"include_gitea": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Include README/docs dal repo Gitea collegato",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "save_context",
|
||||
"description": "Salva o appende memoria persistente in un progetto",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["project_id", "content"],
|
||||
"properties": {
|
||||
"project_id": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"mode": {"type": "string", "enum": ["append", "replace"], "default": "append"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "archive_project",
|
||||
"description": "Archivia o ripristina un progetto",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["project_id"],
|
||||
"properties": {
|
||||
"project_id": {"type": "string"},
|
||||
"archived": {"type": "boolean", "default": True},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "search_context",
|
||||
"description": "Ricerca semantica nei contesti salvati dell'utente",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "search_knowledge",
|
||||
"description": "Ricerca semantica su Paperless, contesti salvati e repository Gitea indicizzati",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "search_gitea_knowledge",
|
||||
"description": "Ricerca semantica solo sui file Gitea indicizzati (runbook, markdown, codice)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "list_gitea_indexed_files",
|
||||
"description": "Elenca gli ultimi file Gitea indicizzati nel vector store",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo": {"type": "string", "description": "Filtra per owner/name"},
|
||||
"limit": {"type": "integer", "default": 30},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "reindex_gitea_repo",
|
||||
"description": "Re-indicizza i file testo di un repository Gitea (admin o owner repo)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["repo"],
|
||||
"properties": {
|
||||
"repo": {"type": "string", "description": "owner/name, es. daniele/rete"},
|
||||
"max_files": {
|
||||
"type": "integer",
|
||||
"description": "Limite file per questa esecuzione (default: config globale)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_document",
|
||||
"description": "Recupera il contenuto testuale di un documento Paperless per ID",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["doc_id"],
|
||||
"properties": {"doc_id": {"type": "integer"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "list_recent_documents",
|
||||
"description": "Elenca gli ultimi documenti indicizzati",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"limit": {"type": "integer", "default": 20}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "reindex_document",
|
||||
"description": "Re-indicizza un documento Paperless (admin)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["doc_id"],
|
||||
"properties": {"doc_id": {"type": "integer"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "list_repos",
|
||||
"description": "Elenca i repository Gitea accessibili all'utente corrente",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {"type": "integer", "default": 1},
|
||||
"limit": {"type": "integer", "default": 50},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_file",
|
||||
"description": "Legge un file (o elenca una directory) da un repository Gitea",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["repo", "path"],
|
||||
"properties": {
|
||||
"repo": {"type": "string", "description": "owner/name, es. daniele/rete"},
|
||||
"path": {"type": "string", "description": "Percorso nel repo, es. ha/RUNBOOK-failover.md"},
|
||||
"ref": {"type": "string", "description": "Branch o tag (default: branch predefinito del repo)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "search_code",
|
||||
"description": "Cerca testo nel codice o nei file di un repository Gitea",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"repo": {"type": "string", "description": "Limita la ricerca a owner/name"},
|
||||
"limit": {"type": "integer", "default": 20},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "list_issues",
|
||||
"description": "Elenca le issue di un repository Gitea",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["repo"],
|
||||
"properties": {
|
||||
"repo": {"type": "string"},
|
||||
"state": {"type": "string", "enum": ["open", "closed", "all"], "default": "open"},
|
||||
"page": {"type": "integer", "default": 1},
|
||||
"limit": {"type": "integer", "default": 20},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_issue",
|
||||
"description": "Recupera una issue Gitea per numero",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["repo", "number"],
|
||||
"properties": {
|
||||
"repo": {"type": "string"},
|
||||
"number": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Crea una nuova issue su un repository Gitea",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["repo", "title"],
|
||||
"properties": {
|
||||
"repo": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"body": {"type": "string", "default": ""},
|
||||
"labels": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "create_gitea_repo",
|
||||
"description": "Crea un nuovo repository Gitea sotto l'utente corrente (workspace personale)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Nome repo (es. progetti)"},
|
||||
"private": {"type": "boolean", "default": True},
|
||||
"description": {"type": "string", "default": ""},
|
||||
"auto_init": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Crea README.md iniziale",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "create_or_update_file",
|
||||
"description": "Crea o aggiorna un file su Gitea (commit singolo via API, equivalente a push di un file)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["repo", "path", "content", "message"],
|
||||
"properties": {
|
||||
"repo": {"type": "string", "description": "owner/name"},
|
||||
"path": {"type": "string", "description": "Percorso file nel repo"},
|
||||
"content": {"type": "string", "description": "Contenuto testo del file"},
|
||||
"message": {"type": "string", "description": "Messaggio di commit"},
|
||||
"branch": {"type": "string", "description": "Branch (default: branch principale del repo)"},
|
||||
"reindex": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Aggiorna subito il vector store RAG per questo file",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_home_dashboard",
|
||||
"description": "Dashboard Loogle Casa: meteo, alert, rete, notifiche",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "get_home_weather",
|
||||
"description": "Meteo attuale e previsioni per casa (Loogle Casa)",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "get_network_overview",
|
||||
"description": "Panoramica rete domestica: dispositivi online/offline, IP pubblico",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "get_network_failover_status",
|
||||
"description": "Stato cluster failover LOOGLE (Pi, NAS, servizi)",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "get_ha_entity",
|
||||
"description": "Legge lo stato di un'entità Home Assistant (read-only)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["entity_id"],
|
||||
"properties": {
|
||||
"entity_id": {
|
||||
"type": "string",
|
||||
"description": "Es. switch.pompa_pozzo, sensor.temperatura_sala",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "list_ha_entities",
|
||||
"description": "Elenca entità Home Assistant, opzionalmente filtrate per dominio",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"description": "Filtra per dominio: switch, sensor, light, climate, …",
|
||||
},
|
||||
"limit": {"type": "integer", "default": 50},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "search_ha_entities",
|
||||
"description": "Cerca entità Home Assistant per nome o entity_id",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 20},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_irrigation_status",
|
||||
"description": "Stato irrigazione: zone, programma, pozzo, sensori, connessione HA",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "get_irrigation_zones",
|
||||
"description": "Elenco zone irrigazione con stato valvole e portata",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "get_irrigation_history",
|
||||
"description": "Storico irrigazioni recenti",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"limit": {"type": "integer", "default": 30}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_turni_status",
|
||||
"description": "Stato servizio Turni-Live (versione, ambiente)",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "get_my_shifts",
|
||||
"description": "Turni di lavoro dell'utente corrente (Turni-Live)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from_date": {"type": "string", "description": "ISO date YYYY-MM-DD"},
|
||||
"to_date": {"type": "string", "description": "ISO date YYYY-MM-DD"},
|
||||
"limit": {"type": "integer", "default": 50},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "list_turni_doctors",
|
||||
"description": "Elenco medici/operatori in Turni-Live",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "search_apps_knowledge",
|
||||
"description": "Ricerca semantica su storico Irrigazione e Turni indicizzati",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["irrigazione", "turni"],
|
||||
"description": "Filtra per sorgente app",
|
||||
},
|
||||
"limit": {"type": "integer", "default": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "list_apps_indexed",
|
||||
"description": "Elenca record Irrigazione/Turni indicizzati nel vector store",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string", "enum": ["irrigazione", "turni"]},
|
||||
"limit": {"type": "integer", "default": 30},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "reindex_apps",
|
||||
"description": "Forza re-indicizzazione RAG da Irrigazione e/o Turni (admin)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["irrigazione", "turni", "all"],
|
||||
"default": "all",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _text_result(payload: Any) -> dict:
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)}]}
|
||||
|
||||
|
||||
def call_tool(name: str, arguments: dict, claims: dict) -> dict:
|
||||
username = claims["sub"]
|
||||
is_admin = "admin" in (claims.get("scope") or "").split()
|
||||
|
||||
if name == "ping":
|
||||
audit.log_tool(username, name)
|
||||
return _text_result({"pong": True, "service": "loogle-mcp"})
|
||||
|
||||
if name == "whoami":
|
||||
audit.log_tool(username, name)
|
||||
return _text_result({"username": username, "scope": claims.get("scope"), "is_admin": is_admin})
|
||||
|
||||
if name == "list_projects":
|
||||
if not has_scope(claims, "context:read"):
|
||||
raise PermissionError("Scope context:read richiesto")
|
||||
projects = context_store.list_projects(username, arguments.get("include_archived", False))
|
||||
audit.log_tool(username, name)
|
||||
return _text_result({"projects": projects})
|
||||
|
||||
if name == "create_project":
|
||||
if not has_scope(claims, "context:write"):
|
||||
raise PermissionError("Scope context:write richiesto")
|
||||
gitea_repo = arguments.get("gitea_repo")
|
||||
if gitea_repo and not has_scope(claims, "gitea:read"):
|
||||
raise PermissionError("Scope gitea:read richiesto per collegare un repository")
|
||||
try:
|
||||
meta = context_store.create_project(
|
||||
username,
|
||||
arguments["title"],
|
||||
arguments.get("tags"),
|
||||
gitea_repo=gitea_repo,
|
||||
seed_from_gitea=bool(arguments.get("seed_from_gitea")),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
raise PermissionError(str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
audit.log_tool(username, name, meta["id"])
|
||||
return _text_result(meta)
|
||||
|
||||
if name == "link_project_repo":
|
||||
if not has_scope(claims, "context:write"):
|
||||
raise PermissionError("Scope context:write richiesto")
|
||||
gitea_repo = arguments.get("gitea_repo")
|
||||
if gitea_repo and not has_scope(claims, "gitea:read"):
|
||||
raise PermissionError("Scope gitea:read richiesto per collegare un repository")
|
||||
try:
|
||||
meta = context_store.link_project_repo(
|
||||
username,
|
||||
arguments["project_id"],
|
||||
gitea_repo=gitea_repo,
|
||||
seed_from_gitea=bool(arguments.get("seed_from_gitea")),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
raise PermissionError(str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
audit.log_tool(username, name, arguments["project_id"])
|
||||
return _text_result(meta)
|
||||
|
||||
if name == "get_project_context":
|
||||
if not has_scope(claims, "context:read"):
|
||||
raise PermissionError("Scope context:read richiesto")
|
||||
include_gitea = arguments.get("include_gitea", True)
|
||||
if include_gitea and not has_scope(claims, "gitea:read"):
|
||||
include_gitea = False
|
||||
data = context_store.get_project_context(
|
||||
username,
|
||||
arguments["project_id"],
|
||||
arguments.get("session_limit", 5),
|
||||
include_gitea=include_gitea,
|
||||
)
|
||||
if include_gitea is False and arguments.get("include_gitea", True):
|
||||
meta = data.get("meta") or {}
|
||||
if meta.get("gitea_repo"):
|
||||
data["gitea"] = {
|
||||
"linked": True,
|
||||
"repo": meta["gitea_repo"],
|
||||
"available": False,
|
||||
"error": "Scope gitea:read richiesto per arricchimento repository",
|
||||
}
|
||||
audit.log_tool(username, name, arguments["project_id"])
|
||||
return _text_result(data)
|
||||
|
||||
if name == "save_context":
|
||||
if not has_scope(claims, "context:write"):
|
||||
raise PermissionError("Scope context:write richiesto")
|
||||
meta = context_store.save_context(
|
||||
username,
|
||||
arguments["project_id"],
|
||||
arguments["content"],
|
||||
arguments.get("mode", "append"),
|
||||
)
|
||||
try:
|
||||
indexer.index_context_snippet(
|
||||
username, arguments["project_id"], arguments["content"], meta["title"]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
audit.log_tool(username, name, arguments["project_id"])
|
||||
return _text_result(meta)
|
||||
|
||||
if name == "archive_project":
|
||||
if not has_scope(claims, "context:write"):
|
||||
raise PermissionError("Scope context:write richiesto")
|
||||
meta = context_store.archive_project(
|
||||
username, arguments["project_id"], arguments.get("archived", True)
|
||||
)
|
||||
audit.log_tool(username, name, arguments["project_id"])
|
||||
return _text_result(meta)
|
||||
|
||||
if name == "search_context":
|
||||
if not has_scope(claims, "context:read"):
|
||||
raise PermissionError("Scope context:read richiesto")
|
||||
hits = indexer.search_context(username, arguments["query"], arguments.get("limit", 8))
|
||||
audit.log_tool(username, name, detail={"query": arguments["query"]})
|
||||
return _text_result({"results": hits})
|
||||
|
||||
if name == "search_knowledge":
|
||||
if not has_scope(claims, "knowledge:read"):
|
||||
raise PermissionError("Scope knowledge:read richiesto")
|
||||
hits = indexer.search_knowledge(
|
||||
username, arguments["query"], arguments.get("limit", 8), is_admin=is_admin
|
||||
)
|
||||
audit.log_tool(username, name, detail={"query": arguments["query"]})
|
||||
return _text_result({"results": hits})
|
||||
|
||||
if name == "search_gitea_knowledge":
|
||||
if not has_scope(claims, "knowledge:read"):
|
||||
raise PermissionError("Scope knowledge:read richiesto")
|
||||
if not gitea.is_configured(username):
|
||||
raise PermissionError("Gitea non configurato per questo utente")
|
||||
hits = indexer.search_gitea_knowledge(
|
||||
username, arguments["query"], arguments.get("limit", 8), is_admin=is_admin
|
||||
)
|
||||
audit.log_tool(username, name, detail={"query": arguments["query"]})
|
||||
return _text_result({"results": hits})
|
||||
|
||||
if name == "list_gitea_indexed_files":
|
||||
if not has_scope(claims, "knowledge:read"):
|
||||
raise PermissionError("Scope knowledge:read richiesto")
|
||||
files = gitea_indexer.list_indexed_files(
|
||||
arguments.get("limit", 30),
|
||||
repo=arguments.get("repo"),
|
||||
)
|
||||
audit.log_tool(username, name, detail={"repo": arguments.get("repo")})
|
||||
return _text_result({"files": files, "count": len(files)})
|
||||
|
||||
if name == "reindex_gitea_repo":
|
||||
if not has_scope(claims, "gitea:read"):
|
||||
raise PermissionError("Scope gitea:read richiesto")
|
||||
if not gitea.is_configured(username):
|
||||
raise PermissionError("Gitea non configurato per questo utente")
|
||||
repo = arguments["repo"]
|
||||
owner = repo.split("/", 1)[0].lower()
|
||||
if not is_admin and owner != username:
|
||||
raise PermissionError("Puoi re-indicizzare solo repository di cui sei owner")
|
||||
try:
|
||||
result = gitea_indexer.index_repo(
|
||||
repo,
|
||||
username=username,
|
||||
force=True,
|
||||
max_files=arguments.get("max_files"),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise PermissionError(f"Re-indicizzazione Gitea fallita: {exc}") from exc
|
||||
audit.log_tool(username, name, detail={"repo": repo})
|
||||
return _text_result(result)
|
||||
|
||||
if name == "get_document":
|
||||
if not has_scope(claims, "knowledge:read"):
|
||||
raise PermissionError("Scope knowledge:read richiesto")
|
||||
try:
|
||||
text = paperless.download_document_text(int(arguments["doc_id"]), username=username)
|
||||
except Exception as exc:
|
||||
raise PermissionError(f"Documento non accessibile con il tuo account Paperless: {exc}")
|
||||
audit.log_tool(username, name, str(arguments["doc_id"]))
|
||||
return _text_result({"doc_id": arguments["doc_id"], "content": text})
|
||||
|
||||
if name == "list_recent_documents":
|
||||
if not has_scope(claims, "knowledge:read"):
|
||||
raise PermissionError("Scope knowledge:read richiesto")
|
||||
docs = indexer.list_recent_documents(arguments.get("limit", 20))
|
||||
audit.log_tool(username, name)
|
||||
return _text_result({"documents": docs})
|
||||
|
||||
if name == "reindex_document":
|
||||
if not is_admin:
|
||||
raise PermissionError("Scope admin richiesto")
|
||||
result = indexer.index_document(int(arguments["doc_id"]), force=True)
|
||||
audit.log_tool(username, name, str(arguments["doc_id"]))
|
||||
return _text_result(result)
|
||||
|
||||
if name in (
|
||||
"list_repos", "get_file", "search_code", "list_issues", "get_issue", "create_issue",
|
||||
"create_gitea_repo", "create_or_update_file",
|
||||
):
|
||||
if not gitea.is_configured(username):
|
||||
raise PermissionError(
|
||||
"Gitea non configurato per questo utente. "
|
||||
"Aggiungi GITEA_API_TOKEN_{USER} in .env — vedi docs/GITEA-TOKEN.md"
|
||||
)
|
||||
|
||||
if name == "list_repos":
|
||||
if not has_scope(claims, "gitea:read"):
|
||||
raise PermissionError("Scope gitea:read richiesto")
|
||||
try:
|
||||
result = gitea.list_repos(
|
||||
username=username,
|
||||
page=int(arguments.get("page", 1)),
|
||||
limit=int(arguments.get("limit", 50)),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise PermissionError(f"Gitea non accessibile: {exc}") from exc
|
||||
audit.log_tool(username, name)
|
||||
return _text_result(result)
|
||||
|
||||
if name == "get_file":
|
||||
if not has_scope(claims, "gitea:read"):
|
||||
raise PermissionError("Scope gitea:read richiesto")
|
||||
try:
|
||||
result = gitea.get_file(
|
||||
arguments["repo"],
|
||||
arguments["path"],
|
||||
ref=arguments.get("ref"),
|
||||
username=username,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise PermissionError(f"File Gitea non accessibile: {exc}") from exc
|
||||
audit.log_tool(username, name, detail={"repo": arguments["repo"], "path": arguments["path"]})
|
||||
return _text_result(result)
|
||||
|
||||
if name == "search_code":
|
||||
if not has_scope(claims, "gitea:read"):
|
||||
raise PermissionError("Scope gitea:read richiesto")
|
||||
try:
|
||||
result = gitea.search_code(
|
||||
arguments["query"],
|
||||
repo=arguments.get("repo"),
|
||||
limit=int(arguments.get("limit", 20)),
|
||||
username=username,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise PermissionError(f"Ricerca Gitea fallita: {exc}") from exc
|
||||
audit.log_tool(username, name, detail={"query": arguments["query"], "repo": arguments.get("repo")})
|
||||
return _text_result(result)
|
||||
|
||||
if name == "list_issues":
|
||||
if not has_scope(claims, "gitea:read"):
|
||||
raise PermissionError("Scope gitea:read richiesto")
|
||||
try:
|
||||
result = gitea.list_issues(
|
||||
arguments["repo"],
|
||||
state=arguments.get("state", "open"),
|
||||
page=int(arguments.get("page", 1)),
|
||||
limit=int(arguments.get("limit", 20)),
|
||||
username=username,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise PermissionError(f"Issue Gitea non accessibili: {exc}") from exc
|
||||
audit.log_tool(username, name, detail={"repo": arguments["repo"]})
|
||||
return _text_result(result)
|
||||
|
||||
if name == "get_issue":
|
||||
if not has_scope(claims, "gitea:read"):
|
||||
raise PermissionError("Scope gitea:read richiesto")
|
||||
try:
|
||||
result = gitea.get_issue(
|
||||
arguments["repo"],
|
||||
int(arguments["number"]),
|
||||
username=username,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise PermissionError(f"Issue Gitea non accessibile: {exc}") from exc
|
||||
audit.log_tool(username, name, detail={"repo": arguments["repo"], "number": arguments["number"]})
|
||||
return _text_result(result)
|
||||
|
||||
if name == "create_issue":
|
||||
if not has_scope(claims, "gitea:write"):
|
||||
raise PermissionError("Scope gitea:write richiesto")
|
||||
try:
|
||||
result = gitea.create_issue(
|
||||
arguments["repo"],
|
||||
arguments["title"],
|
||||
body=arguments.get("body", ""),
|
||||
labels=arguments.get("labels"),
|
||||
username=username,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise PermissionError(f"Creazione issue Gitea fallita: {exc}") from exc
|
||||
audit.log_tool(username, name, detail={"repo": arguments["repo"], "title": arguments["title"]})
|
||||
return _text_result(result)
|
||||
|
||||
if name == "create_gitea_repo":
|
||||
if not has_scope(claims, "gitea:write"):
|
||||
raise PermissionError("Scope gitea:write richiesto")
|
||||
try:
|
||||
result = gitea.create_repo(
|
||||
arguments["name"],
|
||||
username=username,
|
||||
private=bool(arguments.get("private", True)),
|
||||
description=arguments.get("description", ""),
|
||||
auto_init=bool(arguments.get("auto_init", True)),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise PermissionError(f"Creazione repo Gitea fallita: {exc}") from exc
|
||||
audit.log_tool(username, name, detail={"name": arguments["name"]})
|
||||
return _text_result(result)
|
||||
|
||||
if name == "create_or_update_file":
|
||||
if not has_scope(claims, "gitea:write"):
|
||||
raise PermissionError("Scope gitea:write richiesto")
|
||||
repo = arguments["repo"]
|
||||
try:
|
||||
gitea.assert_repo_owner(username, repo, is_admin=is_admin)
|
||||
result = gitea.create_or_update_file(
|
||||
repo,
|
||||
arguments["path"],
|
||||
arguments["content"],
|
||||
arguments["message"],
|
||||
branch=arguments.get("branch"),
|
||||
username=username,
|
||||
)
|
||||
if arguments.get("reindex", True):
|
||||
try:
|
||||
owner, repo_name = gitea.parse_repo(repo)
|
||||
meta = gitea._request(
|
||||
"GET", f"/repos/{owner}/{repo_name}", username=username
|
||||
)
|
||||
private = bool(meta.get("private"))
|
||||
idx = gitea_indexer.index_file(
|
||||
repo,
|
||||
arguments["path"],
|
||||
username=username,
|
||||
private=private,
|
||||
force=True,
|
||||
)
|
||||
result["reindex"] = idx
|
||||
except Exception:
|
||||
result["reindex"] = {"skipped": True}
|
||||
except PermissionError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise PermissionError(f"Scrittura file Gitea fallita: {exc}") from exc
|
||||
audit.log_tool(
|
||||
username,
|
||||
name,
|
||||
detail={"repo": repo, "path": arguments["path"], "action": result.get("action")},
|
||||
)
|
||||
return _text_result(result)
|
||||
|
||||
if name in (
|
||||
"get_home_dashboard", "get_home_weather", "get_network_overview",
|
||||
"get_network_failover_status",
|
||||
):
|
||||
if not has_scope(claims, "home:read"):
|
||||
raise PermissionError("Scope home:read richiesto")
|
||||
if not casa.is_configured(username):
|
||||
raise PermissionError(
|
||||
"Loogle Casa non configurato. Imposta LOOGLE_CASA_PASSWORD_{USER} in .env"
|
||||
)
|
||||
try:
|
||||
if name == "get_home_dashboard":
|
||||
payload = casa.get_dashboard(username)
|
||||
elif name == "get_home_weather":
|
||||
payload = casa.get_weather_home(username)
|
||||
elif name == "get_network_overview":
|
||||
payload = casa.get_network_overview(username)
|
||||
else:
|
||||
payload = casa.get_network_failover_status(username)
|
||||
except Exception as exc:
|
||||
raise PermissionError(f"Loogle Casa non accessibile: {exc}") from exc
|
||||
audit.log_tool(username, name)
|
||||
return _text_result(payload)
|
||||
|
||||
if name in ("get_ha_entity", "list_ha_entities", "search_ha_entities"):
|
||||
if not has_scope(claims, "home:read"):
|
||||
raise PermissionError("Scope home:read richiesto")
|
||||
if not homeassistant.is_configured():
|
||||
raise PermissionError("Home Assistant non configurato — imposta HA_TOKEN in .env")
|
||||
try:
|
||||
if name == "get_ha_entity":
|
||||
payload = homeassistant.get_entity(arguments["entity_id"])
|
||||
elif name == "list_ha_entities":
|
||||
payload = {
|
||||
"entities": homeassistant.list_entities(
|
||||
domain=arguments.get("domain"),
|
||||
limit=int(arguments.get("limit", 50)),
|
||||
),
|
||||
}
|
||||
else:
|
||||
payload = {
|
||||
"results": homeassistant.search_entities(
|
||||
arguments["query"],
|
||||
limit=int(arguments.get("limit", 20)),
|
||||
),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise PermissionError(f"Home Assistant non accessibile: {exc}") from exc
|
||||
audit.log_tool(username, name, detail=arguments.get("entity_id") or arguments.get("query"))
|
||||
return _text_result(payload)
|
||||
|
||||
if name in ("get_irrigation_status", "get_irrigation_zones", "get_irrigation_history"):
|
||||
if not has_scope(claims, "irrigation:read"):
|
||||
raise PermissionError("Scope irrigation:read richiesto")
|
||||
if not irrigazione.is_configured(username):
|
||||
raise PermissionError(
|
||||
"Irrigazione non configurata. Imposta IRRIGAZIONE_PASSWORD_{USER} in .env"
|
||||
)
|
||||
try:
|
||||
if name == "get_irrigation_status":
|
||||
payload = irrigazione.get_status(username)
|
||||
elif name == "get_irrigation_zones":
|
||||
payload = irrigazione.get_zones(username)
|
||||
else:
|
||||
payload = irrigazione.get_history(username, int(arguments.get("limit", 30)))
|
||||
except Exception as exc:
|
||||
raise PermissionError(f"Irrigazione non accessibile: {exc}") from exc
|
||||
audit.log_tool(username, name)
|
||||
return _text_result(payload)
|
||||
|
||||
if name in ("get_turni_status", "get_my_shifts", "list_turni_doctors"):
|
||||
if name != "get_turni_status" and not has_scope(claims, "turni:read"):
|
||||
raise PermissionError("Scope turni:read richiesto")
|
||||
try:
|
||||
if name == "get_turni_status":
|
||||
payload = turni.get_status()
|
||||
elif name == "list_turni_doctors":
|
||||
if not turni.is_configured(username):
|
||||
raise PermissionError("Turni non configurato per questo utente")
|
||||
payload = {"doctors": turni.list_doctors(username)}
|
||||
else:
|
||||
if not turni.is_configured(username):
|
||||
raise PermissionError("Turni non configurato per questo utente")
|
||||
payload = turni.get_my_shifts(
|
||||
username,
|
||||
from_date=arguments.get("from_date"),
|
||||
to_date=arguments.get("to_date"),
|
||||
limit=int(arguments.get("limit", 50)),
|
||||
)
|
||||
except PermissionError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise PermissionError(f"Turni non accessibile: {exc}") from exc
|
||||
audit.log_tool(username, name)
|
||||
return _text_result(payload)
|
||||
|
||||
if name == "search_apps_knowledge":
|
||||
if not has_scope(claims, "knowledge:read"):
|
||||
raise PermissionError("Scope knowledge:read richiesto")
|
||||
hits = apps_indexer.search_apps_knowledge(
|
||||
arguments["query"],
|
||||
limit=int(arguments.get("limit", 8)),
|
||||
source=arguments.get("source"),
|
||||
)
|
||||
audit.log_tool(username, name, detail={"query": arguments["query"]})
|
||||
return _text_result({"results": hits})
|
||||
|
||||
if name == "list_apps_indexed":
|
||||
if not has_scope(claims, "knowledge:read"):
|
||||
raise PermissionError("Scope knowledge:read richiesto")
|
||||
records = apps_indexer.list_indexed_records(
|
||||
source=arguments.get("source"),
|
||||
limit=int(arguments.get("limit", 30)),
|
||||
)
|
||||
audit.log_tool(username, name)
|
||||
return _text_result({"records": records, "count": len(records)})
|
||||
|
||||
if name == "reindex_apps":
|
||||
if not is_admin:
|
||||
raise PermissionError("Scope admin richiesto")
|
||||
src = arguments.get("source", "all")
|
||||
if src == "irrigazione":
|
||||
result = apps_indexer.index_irrigazione()
|
||||
elif src == "turni":
|
||||
result = apps_indexer.index_turni()
|
||||
else:
|
||||
result = apps_indexer.index_all()
|
||||
audit.log_tool(username, name, detail={"source": src})
|
||||
return _text_result(result)
|
||||
|
||||
raise ValueError(f"Tool sconosciuto: {name}")
|
||||
|
||||
|
||||
def list_resources(claims: dict) -> list:
|
||||
username = claims["sub"]
|
||||
return context_store.list_resources(username)
|
||||
|
||||
|
||||
def read_resource(uri: str, claims: dict) -> dict:
|
||||
username = claims["sub"]
|
||||
return context_store.read_resource(username, uri)
|
||||
@@ -0,0 +1,322 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""OAuth 2.1 Authorization Code + PKCE."""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from . import auth
|
||||
from .db import get_conn
|
||||
from .jwt_utils import (
|
||||
ACCESS_TOKEN_HOURS,
|
||||
base_url,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_access_token,
|
||||
revoke_refresh_token,
|
||||
scopes_for_user,
|
||||
consume_refresh_token,
|
||||
)
|
||||
|
||||
|
||||
def _pkce_valid(code_verifier: str, challenge: str, method: str) -> bool:
|
||||
if method != "S256":
|
||||
return False
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
computed = base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
return computed == challenge
|
||||
|
||||
|
||||
TRUSTED_REDIRECT_URIS = frozenset({
|
||||
"https://claude.ai/api/mcp/auth_callback",
|
||||
"https://chatgpt.com/connector_platform_oauth_redirect",
|
||||
"https://chat.openai.com/connector_platform_oauth_redirect",
|
||||
# Cursor IDE / Agents (docs.cursor.com/mcp)
|
||||
"https://www.cursor.com/agents/mcp/oauth/callback",
|
||||
"http://localhost:8787/callback",
|
||||
# Legacy Cursor desktop
|
||||
"cursor://anysphere.cursor-mcp/oauth/callback",
|
||||
})
|
||||
|
||||
|
||||
def ensure_default_client() -> None:
|
||||
clients = [
|
||||
(
|
||||
os.environ.get("MCP_OAUTH_CLIENT_ID", "loogle-mcp-public"),
|
||||
"Loogle MCP Public",
|
||||
[
|
||||
"https://chatgpt.com/connector_platform_oauth_redirect",
|
||||
"https://chat.openai.com/connector_platform_oauth_redirect",
|
||||
"https://claude.ai/api/mcp/auth_callback",
|
||||
"https://www.cursor.com/agents/mcp/oauth/callback",
|
||||
"http://localhost:8787/callback",
|
||||
"cursor://anysphere.cursor-mcp/oauth/callback",
|
||||
"http://127.0.0.1:*/callback",
|
||||
"http://localhost:*/callback",
|
||||
],
|
||||
),
|
||||
(
|
||||
"cursor",
|
||||
"Cursor IDE",
|
||||
[
|
||||
"https://www.cursor.com/agents/mcp/oauth/callback",
|
||||
"http://localhost:8787/callback",
|
||||
"cursor://anysphere.cursor-mcp/oauth/callback",
|
||||
],
|
||||
),
|
||||
(
|
||||
"claude-desktop",
|
||||
"Claude Desktop/App",
|
||||
["https://claude.ai/api/mcp/auth_callback"],
|
||||
),
|
||||
(
|
||||
"claude-ai",
|
||||
"Claude.ai",
|
||||
["https://claude.ai/api/mcp/auth_callback"],
|
||||
),
|
||||
]
|
||||
conn = get_conn()
|
||||
for client_id, client_name, redirect_uris in clients:
|
||||
row = conn.execute(
|
||||
"SELECT client_id FROM oauth_clients WHERE client_id=?", (client_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute(
|
||||
"UPDATE oauth_clients SET client_name=?, redirect_uris=? WHERE client_id=?",
|
||||
(client_name, json.dumps(redirect_uris), client_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)",
|
||||
(client_id, client_name, json.dumps(redirect_uris)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _is_trusted_redirect_uri(redirect_uri: str) -> bool:
|
||||
if redirect_uri in TRUSTED_REDIRECT_URIS:
|
||||
return True
|
||||
parsed = urlparse(redirect_uri)
|
||||
if parsed.scheme == "http" and parsed.hostname in ("127.0.0.1", "localhost"):
|
||||
if (parsed.path or "").endswith("/callback"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def register_client(client_name: str, redirect_uris: list[str]) -> dict:
|
||||
client_id = secrets.token_urlsafe(16)
|
||||
for uri in redirect_uris:
|
||||
if not _is_trusted_redirect_uri(uri):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Redirect URI non consentito: {uri}",
|
||||
)
|
||||
get_conn().execute(
|
||||
"INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)",
|
||||
(client_id, client_name, json.dumps(redirect_uris)),
|
||||
)
|
||||
get_conn().commit()
|
||||
return {"client_id": client_id, "client_name": client_name, "redirect_uris": redirect_uris}
|
||||
|
||||
|
||||
def _client_redirect_uris(client_id: str) -> list[str]:
|
||||
row = get_conn().execute(
|
||||
"SELECT redirect_uris FROM oauth_clients WHERE client_id=?", (client_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return []
|
||||
return json.loads(row["redirect_uris"])
|
||||
|
||||
|
||||
def _ensure_client_for_redirect(client_id: str, redirect_uri: str) -> None:
|
||||
"""Registra client OAuth al volo (Claude usa spesso client_id = username)."""
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT redirect_uris FROM oauth_clients WHERE client_id=?", (client_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
uris = set(json.loads(row["redirect_uris"]))
|
||||
if redirect_uri not in uris:
|
||||
uris.add(redirect_uri)
|
||||
conn.execute(
|
||||
"UPDATE oauth_clients SET redirect_uris=? WHERE client_id=?",
|
||||
(json.dumps(sorted(uris)), client_id),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)",
|
||||
(client_id, f"MCP client {client_id}", json.dumps([redirect_uri])),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _redirect_allowed(client_id: str, redirect_uri: str) -> bool:
|
||||
if _is_trusted_redirect_uri(redirect_uri):
|
||||
_ensure_client_for_redirect(client_id, redirect_uri)
|
||||
return True
|
||||
allowed = _client_redirect_uris(client_id)
|
||||
if redirect_uri in allowed:
|
||||
return True
|
||||
parsed = urlparse(redirect_uri)
|
||||
for pattern in allowed:
|
||||
if "*" in pattern:
|
||||
pp = urlparse(pattern.replace("*", "placeholder"))
|
||||
if parsed.scheme == pp.scheme and parsed.netloc.endswith(pp.netloc.split("placeholder")[-1]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def create_auth_code(
|
||||
client_id: str,
|
||||
user_id: int,
|
||||
redirect_uri: str,
|
||||
scope: str,
|
||||
code_challenge: Optional[str],
|
||||
code_challenge_method: Optional[str],
|
||||
) -> str:
|
||||
code = secrets.token_urlsafe(32)
|
||||
expires = (
|
||||
datetime.datetime.utcnow() + datetime.timedelta(minutes=10)
|
||||
).strftime("%Y-%m-%d %H:%M:%S")
|
||||
get_conn().execute(
|
||||
"INSERT INTO oauth_codes(code,client_id,user_id,redirect_uri,scope,code_challenge,code_challenge_method,expires_at)"
|
||||
" VALUES (?,?,?,?,?,?,?,?)",
|
||||
(code, client_id, user_id, redirect_uri, scope, code_challenge, code_challenge_method, expires),
|
||||
)
|
||||
get_conn().commit()
|
||||
return code
|
||||
|
||||
|
||||
def exchange_code(
|
||||
code: str,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
code_verifier: Optional[str],
|
||||
) -> dict:
|
||||
row = get_conn().execute(
|
||||
"SELECT * FROM oauth_codes WHERE code=? AND used=0 AND expires_at > datetime('now')",
|
||||
(code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(400, "Codice non valido o scaduto")
|
||||
row = dict(row)
|
||||
if row["client_id"] != client_id or row["redirect_uri"] != redirect_uri:
|
||||
raise HTTPException(400, "Client o redirect URI non validi")
|
||||
if row.get("code_challenge"):
|
||||
if not code_verifier or not _pkce_valid(code_verifier, row["code_challenge"], row.get("code_challenge_method") or "S256"):
|
||||
raise HTTPException(400, "PKCE verification failed")
|
||||
user = auth.get_user_by_id(row["user_id"])
|
||||
if not user:
|
||||
raise HTTPException(400, "Utente non trovato")
|
||||
get_conn().execute("UPDATE oauth_codes SET used=1 WHERE code=?", (code,))
|
||||
get_conn().commit()
|
||||
scope = scopes_for_user(user, row["scope"])
|
||||
access_token, _ = create_access_token(user, scope, client_id)
|
||||
refresh = create_refresh_token(user["id"], scope, client_id)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ACCESS_TOKEN_HOURS * 3600,
|
||||
"refresh_token": refresh,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
|
||||
def refresh_access_token(refresh_token: str, client_id: str) -> dict:
|
||||
row = consume_refresh_token(refresh_token)
|
||||
if not row or row["client_id"] != client_id:
|
||||
raise HTTPException(400, "Refresh token non valido")
|
||||
user = auth.get_user_by_id(row["user_id"])
|
||||
if not user:
|
||||
raise HTTPException(400, "Utente non trovato")
|
||||
revoke_refresh_token(refresh_token)
|
||||
scope = row["scope"]
|
||||
access_token, _ = create_access_token(user, scope, client_id)
|
||||
refresh = create_refresh_token(user["id"], scope, client_id)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ACCESS_TOKEN_HOURS * 3600,
|
||||
"refresh_token": refresh,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
|
||||
def authorization_server_metadata() -> dict:
|
||||
base = base_url()
|
||||
return {
|
||||
"issuer": base,
|
||||
"authorization_endpoint": f"{base}/authorize",
|
||||
"token_endpoint": f"{base}/token",
|
||||
"registration_endpoint": f"{base}/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
|
||||
"scopes_supported": [
|
||||
"context:read",
|
||||
"context:write",
|
||||
"knowledge:read",
|
||||
"knowledge:write",
|
||||
"gitea:read",
|
||||
"gitea:write",
|
||||
"home:read",
|
||||
"irrigation:read",
|
||||
"turni:read",
|
||||
"admin",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def protected_resource_metadata() -> dict:
|
||||
base = base_url()
|
||||
return {
|
||||
"resource": f"{base}/mcp",
|
||||
"authorization_servers": [base],
|
||||
"scopes_supported": [
|
||||
"context:read",
|
||||
"context:write",
|
||||
"knowledge:read",
|
||||
"knowledge:write",
|
||||
"gitea:read",
|
||||
"gitea:write",
|
||||
"home:read",
|
||||
"irrigation:read",
|
||||
"turni:read",
|
||||
],
|
||||
"bearer_methods_supported": ["header"],
|
||||
}
|
||||
|
||||
|
||||
def build_authorize_redirect(
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str,
|
||||
state: str,
|
||||
code_challenge: Optional[str],
|
||||
code_challenge_method: Optional[str],
|
||||
user_id: int,
|
||||
) -> str:
|
||||
if not _redirect_allowed(client_id, redirect_uri):
|
||||
raise HTTPException(400, "Redirect URI non autorizzato")
|
||||
code = create_auth_code(
|
||||
client_id, user_id, redirect_uri, scope, code_challenge, code_challenge_method
|
||||
)
|
||||
params = {"code": code, "state": state}
|
||||
sep = "&" if "?" in redirect_uri else "?"
|
||||
return f"{redirect_uri}{sep}{urlencode(params)}"
|
||||
|
||||
|
||||
def bearer_claims_from_header(authorization: str) -> Optional[dict]:
|
||||
if not authorization.lower().startswith("bearer "):
|
||||
return None
|
||||
token = authorization[7:].strip()
|
||||
return decode_access_token(token)
|
||||
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Loogle MCP Dashboard</title>
|
||||
<style>
|
||||
:root { --bg:#0f172a; --card:#1e293b; --text:#e2e8f0; --muted:#94a3b8; --accent:#2563eb; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 1rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: .25rem; }
|
||||
.sub { color: var(--muted); margin-bottom: 1.5rem; }
|
||||
.card { background: var(--card); border-radius: 12px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
||||
button { background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: .55rem 1rem; cursor: pointer; }
|
||||
button.secondary { background: #334155; }
|
||||
input { width: 100%; padding: .5rem; border-radius: 6px; border: 1px solid #334155; background: var(--bg); color: var(--text); margin: .35rem 0 .75rem; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
||||
th, td { text-align: left; padding: .45rem .25rem; border-bottom: 1px solid #334155; }
|
||||
.hidden { display: none; }
|
||||
.error { color: #f87171; }
|
||||
code { background: #0b1220; padding: .15rem .35rem; border-radius: 4px; font-size: .85rem; }
|
||||
ul { padding-left: 1.2rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Loogle MCP Hub</h1>
|
||||
<p class="sub">Archivio contesto e knowledge base locale per AI famiglia</p>
|
||||
|
||||
<div id="loginView" class="card">
|
||||
<h2>Accedi</h2>
|
||||
<label>Utente</label>
|
||||
<input id="loginUser" autocomplete="username">
|
||||
<label>Password</label>
|
||||
<input id="loginPass" type="password" autocomplete="current-password">
|
||||
<button onclick="doLogin()">Entra</button>
|
||||
<p id="loginErr" class="error hidden"></p>
|
||||
</div>
|
||||
|
||||
<div id="appView" class="hidden">
|
||||
<div class="card">
|
||||
<strong id="welcome"></strong>
|
||||
<button class="secondary" onclick="doLogout()" style="float:right">Esci</button>
|
||||
<p>MCP URL: <code>https://mcp.loogle.it/mcp</code></p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="pwdCard">
|
||||
<h3>Cambia password</h3>
|
||||
<label>Password attuale</label><input id="oldPwd" type="password">
|
||||
<label>Nuova password</label><input id="newPwd" type="password">
|
||||
<button onclick="changePwd()">Salva</button>
|
||||
<p id="pwdMsg"></p>
|
||||
</div>
|
||||
|
||||
<div class="card hidden" id="adminCard">
|
||||
<h3>Admin — revoca refresh token</h3>
|
||||
<label>Refresh token</label><input id="revokeToken">
|
||||
<button onclick="revokeRefresh()">Revoca</button>
|
||||
<p id="revokeMsg"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Progetti</h3>
|
||||
<div id="projects"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Audit log recente</h3>
|
||||
<table><thead><tr><th>Ora</th><th>Utente</th><th>Tool</th><th>Risorsa</th></tr></thead><tbody id="auditBody"></tbody></table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Collegamenti AI</h3>
|
||||
<ul>
|
||||
<li><strong>ChatGPT:</strong> Impostazioni → Developer → Add MCP Connector → URL sopra</li>
|
||||
<li><strong>Claude:</strong> Settings → Connectors → Add remote MCP server</li>
|
||||
<li><strong>Gemini CLI:</strong> configura server remoto in settings MCP</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function api(path, opts={}) {
|
||||
const r = await fetch(path, { credentials: 'same-origin', headers: {'Content-Type':'application/json', ...(opts.headers||{})}, ...opts });
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.json();
|
||||
}
|
||||
function show(el, on) { document.getElementById(el).classList.toggle('hidden', !on); }
|
||||
async function boot() {
|
||||
try {
|
||||
const me = await api('/api/me');
|
||||
show('loginView', false); show('appView', true);
|
||||
document.getElementById('welcome').textContent = 'Ciao ' + me.username + (me.is_admin ? ' (admin)' : '');
|
||||
if (me.is_admin) document.getElementById('adminCard').classList.remove('hidden');
|
||||
loadProjects(); loadAudit();
|
||||
} catch { show('loginView', true); show('appView', false); }
|
||||
}
|
||||
async function doLogin() {
|
||||
try {
|
||||
await api('/api/login', { method:'POST', body: JSON.stringify({ username: loginUser.value, password: loginPass.value }) });
|
||||
boot();
|
||||
} catch (e) {
|
||||
loginErr.textContent = 'Credenziali non valide'; loginErr.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
async function doLogout() { await api('/api/logout', { method:'POST', body:'{}' }); boot(); }
|
||||
async function changePwd() {
|
||||
try {
|
||||
await api('/api/password', { method:'POST', body: JSON.stringify({ old_password: oldPwd.value, new_password: newPwd.value }) });
|
||||
pwdMsg.textContent = 'Password aggiornata';
|
||||
} catch (e) { pwdMsg.textContent = 'Errore: password attuale errata'; }
|
||||
}
|
||||
async function loadProjects() {
|
||||
const rows = await api('/api/projects');
|
||||
projects.innerHTML = rows.length ? rows.map(p => `<div><strong>${p.title}</strong> <small>${p.id}</small> — agg. ${p.updated_at||p.created_at}</div>`).join('') : '<em>Nessun progetto</em>';
|
||||
}
|
||||
async function loadAudit() {
|
||||
const rows = await api('/api/audit?limit=30');
|
||||
auditBody.innerHTML = rows.map(r => `<tr><td>${r.created_at}</td><td>${r.username}</td><td>${r.tool_name}</td><td>${r.resource_id||''}</td></tr>`).join('');
|
||||
}
|
||||
async function revokeRefresh() {
|
||||
try {
|
||||
await api('/api/admin/revoke-refresh', { method:'POST', body: JSON.stringify({ refresh_token: revokeToken.value }) });
|
||||
revokeMsg.textContent = 'Token revocato';
|
||||
} catch (e) { revokeMsg.textContent = 'Errore revoca'; }
|
||||
}
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,63 @@
|
||||
services:
|
||||
loogle-mcp:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: loogle-mcp:latest
|
||||
container_name: loogle-mcp
|
||||
restart: always
|
||||
ports:
|
||||
- "8700:8700"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=Europe/Rome
|
||||
- MCP_DB=/data/loogle_mcp.db
|
||||
- MCP_CONTEXT_ROOT=/data/context
|
||||
- MCP_AUDIT_LOG=/data/audit.log
|
||||
- MCP_VECTOR_FALLBACK=/data/vector_fallback.db
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- /mnt/ha-apps/mcp/context:/data/context
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8700/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
loogle-mcp-indexer:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: loogle-mcp:latest
|
||||
container_name: loogle-mcp-indexer
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=Europe/Rome
|
||||
- MCP_DB=/data/loogle_mcp.db
|
||||
- MCP_CONTEXT_ROOT=/data/context
|
||||
- MCP_VECTOR_FALLBACK=/data/vector_fallback.db
|
||||
- DS920_SSH_HOST=192.168.128.100
|
||||
- DS920_SSH_USER=daniely
|
||||
- DS920_SSH_KEY=/run/secrets/ds920_ssh_key
|
||||
- DS920_THERMAL_URL=http://192.168.128.100:9191/thermal
|
||||
command: ["python", "-m", "worker.indexer_main"]
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- /mnt/ha-apps/mcp/context:/data/context
|
||||
- /home/daniely/.ssh/id_ed25519:/run/secrets/ds920_ssh_key:ro
|
||||
depends_on:
|
||||
- loogle-mcp
|
||||
|
||||
# Qdrant locale: solo su x86/DS920 — su Pi5 ARM 16K page size non supportato
|
||||
qdrant-local:
|
||||
profiles: ["qdrant-local"]
|
||||
image: qdrant/qdrant:v1.13.2
|
||||
container_name: loogle_mcp_qdrant
|
||||
restart: always
|
||||
ports:
|
||||
- "6333:6333"
|
||||
volumes:
|
||||
- /mnt/ha-apps/mcp/qdrant:/qdrant/storage
|
||||
@@ -0,0 +1,91 @@
|
||||
# Integrazione app homelab — P5/P6/P7
|
||||
|
||||
Tool live e RAG su **Loogle Casa**, **Home Assistant**, **Irrigazione** e **Turni**.
|
||||
|
||||
---
|
||||
|
||||
## P5 — Casa + Home Assistant
|
||||
|
||||
| Tool | Scope | Sorgente |
|
||||
|------|-------|----------|
|
||||
| `get_home_dashboard` | home:read | Loogle Casa |
|
||||
| `get_home_weather` | home:read | Loogle Casa |
|
||||
| `get_network_overview` | home:read | Loogle Casa |
|
||||
| `get_network_failover_status` | home:read | Loogle Casa |
|
||||
| `get_ha_entity` | home:read | Home Assistant |
|
||||
| `list_ha_entities` | home:read | Home Assistant |
|
||||
| `search_ha_entities` | home:read | Home Assistant |
|
||||
|
||||
### Variabili `.env`
|
||||
|
||||
```bash
|
||||
LOOGLE_CASA_URL=https://casa.loogle.it
|
||||
LOOGLE_CASA_API_URL=http://192.168.128.81:5602 # opzionale, LAN
|
||||
LOOGLE_CASA_PASSWORD_DANIELE=... # daniele MCP → admin Casa
|
||||
|
||||
HA_URL=https://ha.loogle.it
|
||||
HA_API_URL=http://192.168.128.81:8123
|
||||
HA_TOKEN=<long-lived token HA>
|
||||
```
|
||||
|
||||
**Mapping utenti MCP → app:**
|
||||
- `daniele` → Casa `admin`, Irrigazione `admin`, Turni `daniely`
|
||||
|
||||
---
|
||||
|
||||
## P6 — Irrigazione + Turni
|
||||
|
||||
| Tool | Scope | Sorgente |
|
||||
|------|-------|----------|
|
||||
| `get_irrigation_status` | irrigation:read | irri.loogle.it |
|
||||
| `get_irrigation_zones` | irrigation:read | irri.loogle.it |
|
||||
| `get_irrigation_history` | irrigation:read | irri.loogle.it |
|
||||
| `get_turni_status` | — (pubblico) | turni.loogle.it |
|
||||
| `get_my_shifts` | turni:read | turni.loogle.it |
|
||||
| `list_turni_doctors` | turni:read | turni.loogle.it |
|
||||
|
||||
```bash
|
||||
IRRIGAZIONE_URL=https://irri.loogle.it
|
||||
IRRIGAZIONE_API_URL=http://192.168.128.81:5601
|
||||
IRRIGAZIONE_PASSWORD_DANIELE=...
|
||||
|
||||
TURNI_URL=https://turni.loogle.it
|
||||
TURNI_PASSWORD_DANIELE=... # utente Turni: daniely
|
||||
# oppure TURNI_JWT_DANIELE=...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P7 — RAG storico app
|
||||
|
||||
Il worker `loogle-mcp-indexer` indicizza periodicamente:
|
||||
- snapshot stato/zone Irrigazione
|
||||
- storico irrigazioni ed eventi
|
||||
- lavori manutenzione giardino
|
||||
- turni e anagrafica medici Turni
|
||||
|
||||
| Collection Qdrant | Contenuto |
|
||||
|-------------------|-----------|
|
||||
| `apps_shared_family` | Irrigazione + Turni (famiglia) |
|
||||
|
||||
| Tool | Scope | Funzione |
|
||||
|------|-------|----------|
|
||||
| `search_apps_knowledge` | knowledge:read | RAG solo app |
|
||||
| `search_knowledge` | knowledge:read | Include anche app |
|
||||
| `list_apps_indexed` | knowledge:read | Stato indicizzazione |
|
||||
| `reindex_apps` | admin | Forza sync |
|
||||
|
||||
```bash
|
||||
APPS_INDEX_ENABLED=yes
|
||||
APPS_INDEX_USER=daniele
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
docker exec loogle-mcp python3 /srv/scripts/test_p5_home.py
|
||||
docker exec loogle-mcp python3 /srv/scripts/test_p6_apps.py
|
||||
docker exec loogle-mcp python3 /srv/scripts/test_p7_apps_rag.py
|
||||
```
|
||||
@@ -0,0 +1,297 @@
|
||||
# Loogle MCP Hub — Architettura e parametri
|
||||
|
||||
Documento di riferimento per la piattaforma MCP domestica LOOGLE.IT.
|
||||
Ultimo aggiornamento: 2026-08-22
|
||||
|
||||
---
|
||||
|
||||
## Scopo
|
||||
|
||||
**Loogle MCP Hub** espone agli assistenti AI (Claude, ChatGPT, Gemini, Cursor) due capacità principali:
|
||||
|
||||
1. **Archivio contesto** — memoria persistente per progetti e task, isolata per utente
|
||||
2. **Knowledge base RAG** — ricerca semantica su documenti Paperless e sui contesti salvati
|
||||
|
||||
Tutti i dati restano in LAN/NAS; 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) │
|
||||
└───────┬─────────────────────────────┬───────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────┐ ┌─────────────────────┐
|
||||
│ SQLite │ │ Vector store │
|
||||
│ /data/*.db │ │ Qdrant DS920 :6333 │
|
||||
│ utenti OAuth │ │ o fallback SQLite │
|
||||
└───────────────┘ └─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ NFS /mnt/ha-apps/mcp/context/{utente}/projects/... │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Paperless docs.loogle.it (sorgente documenti + OCR) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Componenti e percorsi
|
||||
|
||||
| Componente | Host | Porta | Percorso / container |
|
||||
|------------|------|-------|----------------------|
|
||||
| MCP Gateway | Pi-1 | 8700 | `loogle-mcp` |
|
||||
| Indexer | Pi-1 | — | `loogle-mcp-indexer` |
|
||||
| Qdrant (primario) | DS920 | 6333 | opzionale, compose extrema |
|
||||
| Vector fallback | Pi-1 | — | `/data/vector_fallback.db` |
|
||||
| Context NFS | NAS | — | `/mnt/ha-apps/mcp/context/` |
|
||||
| DB SQLite | Pi-1 | — | `/home/daniely/docker/loogle-mcp/data/loogle_mcp.db` |
|
||||
| Compose locale | Pi-1 | — | `/home/daniely/docker/loogle-mcp/` |
|
||||
| Compose failover | Pi-1/Pi-2 | — | `/home/daniely/rete/compose/failover/loogle-mcp-compose.yml` |
|
||||
| Runbook | — | — | `/home/daniely/rete/ha/RUNBOOK-mcp.md` |
|
||||
|
||||
---
|
||||
|
||||
## URL e endpoint
|
||||
|
||||
| Servizio | URL |
|
||||
|----------|-----|
|
||||
| MCP (client AI) | `https://mcp.loogle.it/mcp` |
|
||||
| Dashboard web | `https://mcp.loogle.it/dashboard` |
|
||||
| Health | `https://mcp.loogle.it/health` |
|
||||
| OAuth authorize | `https://mcp.loogle.it/oauth/authorize` |
|
||||
| OAuth token | `https://mcp.loogle.it/oauth/token` |
|
||||
| Metadata OAuth | `https://mcp.loogle.it/.well-known/oauth-authorization-server` |
|
||||
| Metadata risorsa MCP | `https://mcp.loogle.it/.well-known/oauth-protected-resource` |
|
||||
|
||||
---
|
||||
|
||||
## DNS
|
||||
|
||||
| Record | Tipo | Valore | Dove configurato |
|
||||
|--------|------|--------|------------------|
|
||||
| `mcp.loogle.it` | A | `192.168.128.85` (VIP NPM) | Pi-hole `/etc/pihole/pihole.toml` → hosts |
|
||||
| Risoluzione LAN | — | Pi-hole → Unbound → split-horizon | Come `docs.loogle.it`, `pass.loogle.it` |
|
||||
|
||||
**Script setup:** `/home/daniely/rete/scripts/setup-mcp-dns-npm.sh`
|
||||
|
||||
**Verifica:**
|
||||
```bash
|
||||
dig +short mcp.loogle.it @127.0.0.1 # → 192.168.128.85
|
||||
curl -sf https://mcp.loogle.it/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NPM (reverse proxy)
|
||||
|
||||
| Parametro | Valore |
|
||||
|-----------|--------|
|
||||
| Proxy host ID | 30 |
|
||||
| Dominio | `mcp.loogle.it` |
|
||||
| Upstream normale | `192.168.128.80:8700` |
|
||||
| Certificato TLS | `npm-65` (Let's Encrypt) |
|
||||
| Failover route | `rete/ha/npm-routes.conf` riga `30\|mcp.loogle.it\|8700\|...` |
|
||||
| Websocket | ON |
|
||||
| Force SSL | ON |
|
||||
|
||||
**Failover:** `npm-failover-route.sh` aggiorna upstream su Pi-2 / DS920 in base allo scenario HA.
|
||||
|
||||
---
|
||||
|
||||
## Variabili d'ambiente (`.env`)
|
||||
|
||||
File: `/home/daniely/docker/loogle-mcp/.env`
|
||||
|
||||
| Variabile | Descrizione | Esempio |
|
||||
|-----------|-------------|---------|
|
||||
| `MCP_BASE_URL` | URL pubblico (issuer JWT) | `https://mcp.loogle.it` |
|
||||
| `MCP_JWT_SECRET` | Segreto firma token OAuth | *(stringa lunga casuale)* |
|
||||
| `MCP_OAUTH_CLIENT_ID` | Client OAuth pre-registrato | `loogle-mcp-public` |
|
||||
| `PAPERLESS_URL` | API documenti | `https://docs.loogle.it` |
|
||||
| `PAPERLESS_API_TOKEN` | Token API admin (fallback) | vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md) |
|
||||
| `PAPERLESS_API_TOKEN_{USER}` | Token API per utente | `DANIELE`, `LUCIA`, `DAVIDE`, `LUCA` |
|
||||
| `QDRANT_URL` | Vector DB remoto | `http://192.168.128.100:6333` |
|
||||
| `OLLAMA_URL` | Embedding locale | `http://192.168.128.100:11434` |
|
||||
| `OLLAMA_EMBED_MODEL` | Modello embedding | `nomic-embed-text` |
|
||||
| `OPENAI_API_KEY` | Fallback embedding cloud | *(opzionale)* |
|
||||
| `INDEXER_INTERVAL_MINUTES` | Intervallo sync Paperless | `30` |
|
||||
| `THERMAL_TEMP_HARD_C` | Pausa embedding se CPU DS920 ≥ °C | `70` |
|
||||
| `THERMAL_TEMP_SOFT_C` | Rallenta embedding sopra °C | `58` |
|
||||
| `THERMAL_CPU_TARGET_PCT` | Cap load1 (nproc × %) | `40` |
|
||||
| `OLLAMA_NUM_THREAD` | Thread CPU per richiesta embed | `1` |
|
||||
| `OLLAMA_EMBED_DELAY_S` | Pausa tra embed (duty-cycle lento) | `10` |
|
||||
| `DS920_THERMAL_URL` | Probe temp/load DS920 | `http://192.168.128.100:9191/thermal` |
|
||||
|
||||
---
|
||||
|
||||
## Utenti e isolamento
|
||||
|
||||
| Persona | Username | Ruolo | Password iniziale |
|
||||
|---------|----------|-------|-------------------|
|
||||
| Daniele | `daniele` | admin | `daniele` *(cambiarla)* |
|
||||
| Lucia | `lucia` | user | `lucia` |
|
||||
| Davide | `davide` | user | `davide` |
|
||||
| Luca | `luca` | user | `luca` |
|
||||
|
||||
- Ogni utente vede **solo** i propri progetti in `/mnt/ha-apps/mcp/context/{username}/`
|
||||
- I token OAuth JWT contengono `sub` = username; ogni tool filtra per utente
|
||||
- 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 |
|
||||
| `create_project` | context:write | Nuovo progetto |
|
||||
| `get_project_context` | context:read | Legge memoria progetto |
|
||||
| `save_context` | context:write | Salva/appende memoria |
|
||||
| `archive_project` | context:write | Archivia progetto |
|
||||
| `search_context` | context:read | Ricerca semantica contesti |
|
||||
| `search_knowledge` | knowledge:read | RAG Paperless + contesti + Gitea + app |
|
||||
| `search_gitea_knowledge` | knowledge:read | RAG solo Gitea |
|
||||
| `search_apps_knowledge` | knowledge:read | RAG Irrigazione + Turni |
|
||||
| `list_apps_indexed` | knowledge:read | Stato indicizzazione app |
|
||||
| `reindex_apps` | admin | Re-sync RAG app |
|
||||
| `get_document` | knowledge:read | Testo documento Paperless |
|
||||
| `list_recent_documents` | knowledge:read | Ultimi doc indicizzati |
|
||||
| `reindex_document` | admin | Re-indicizza documento |
|
||||
| `get_home_dashboard` | home:read | Dashboard Loogle Casa |
|
||||
| `get_home_weather` | home:read | Meteo casa |
|
||||
| `get_network_overview` | home:read | Panoramica rete LAN |
|
||||
| `get_network_failover_status` | home:read | Stato failover cluster |
|
||||
| `get_ha_entity` | home:read | Entità Home Assistant |
|
||||
| `list_ha_entities` | home:read | Elenco entità HA |
|
||||
| `search_ha_entities` | home:read | Cerca entità HA |
|
||||
| `get_irrigation_status` | irrigation:read | Stato irrigazione |
|
||||
| `get_irrigation_zones` | irrigation:read | Zone irrigazione |
|
||||
| `get_irrigation_history` | irrigation:read | Storico irrigazioni |
|
||||
| `get_turni_status` | — | Stato servizio Turni |
|
||||
| `get_my_shifts` | turni:read | Turni utente corrente |
|
||||
| `list_turni_doctors` | turni:read | Medici Turni-Live |
|
||||
|
||||
Vedi anche tool Gitea in `docs/GITEA-TOKEN.md` e app homelab in `docs/APPS-INTEGRATION.md`.
|
||||
|
||||
---
|
||||
|
||||
## Storage contesto (per progetto)
|
||||
|
||||
```
|
||||
/mnt/ha-apps/mcp/context/{username}/projects/{project-id}/
|
||||
meta.json # titolo, tag, date, stato, gitea_repo (opz.)
|
||||
context.md # memoria persistente
|
||||
sessions/ # snapshot conversazioni
|
||||
artifacts/ # file generati dagli agenti
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vector store / RAG
|
||||
|
||||
| Collection Qdrant | Contenuto |
|
||||
|-------------------|-----------|
|
||||
| `kb_shared_family` | Documenti famiglia (tag non personali) |
|
||||
| `kb_personal_{user}` | Documenti personali |
|
||||
| `ctx_{user}` | Embedding dei contesti agente |
|
||||
| `gitea_shared_family` | File testo da repo Gitea non privati |
|
||||
| `gitea_personal_{user}` | File testo da repo Gitea privati dell'owner |
|
||||
| `apps_shared_family` | Export Irrigazione + Turni (P7) |
|
||||
|
||||
**Nota Pi 5:** Qdrant locale su ARM (page size 16K) non è supportato. Default: Qdrant su DS920 o fallback SQLite in `/data/vector_fallback.db`.
|
||||
|
||||
**Pipeline indexer:** ogni 30 min:
|
||||
1. Paperless → chunk → embedding (Ollama DS920) → upsert `kb_*`
|
||||
2. Gitea (repo configurati in `GITEA_INDEX_REPOS_*`) → file `.md`/`.py`/`.sh` → chunk → upsert `gitea_*`
|
||||
3. Irrigazione + Turni → snapshot/storico → upsert `apps_shared_family` (vedi `docs/APPS-INTEGRATION.md`)
|
||||
|
||||
---
|
||||
|
||||
## Sicurezza
|
||||
|
||||
- TLS terminato su NPM (Let's Encrypt)
|
||||
- OAuth 2.1 Authorization Code + PKCE
|
||||
- JWT access token 1h + refresh token 30 giorni (revocabili)
|
||||
- Audit log: `{timestamp, user, tool, resource_id}` in SQLite
|
||||
- CrowdSec attivo su NPM
|
||||
- Nessun accesso cross-user ai dati
|
||||
|
||||
---
|
||||
|
||||
## Operazioni comuni
|
||||
|
||||
```bash
|
||||
# Stato servizi
|
||||
cd /home/daniely/docker/loogle-mcp && docker compose ps
|
||||
|
||||
# Log
|
||||
docker logs -f loogle-mcp
|
||||
docker logs -f loogle-mcp-indexer
|
||||
|
||||
# Redeploy
|
||||
./scripts/deploy.sh
|
||||
|
||||
# DNS + NPM (se reinstall)
|
||||
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh
|
||||
|
||||
# Failover route
|
||||
/home/daniely/rete/scripts/npm-failover-route.sh normal
|
||||
|
||||
# Backup dati MCP
|
||||
/home/daniely/rete/infra-monitor/mcp-restic-backup.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Failover (tier-b)
|
||||
|
||||
| Scenario | Comportamento |
|
||||
|----------|---------------|
|
||||
| Pi-1 down, Pi-2 up | Stack MCP su Pi-2, NPM upstream → `.81:8700` |
|
||||
| Extrema DS920 | NPM3 + upstream DS920 |
|
||||
| Monitoraggio | Probe `mcp_ok` in `failover-status.sh` |
|
||||
|
||||
---
|
||||
|
||||
## Da completare (post-install)
|
||||
|
||||
1. **`PAPERLESS_API_TOKEN_*`** in `.env` — vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md)
|
||||
2. **Ollama su DS920** con `nomic-embed-text` per embedding locali
|
||||
3. **Qdrant su DS920** (opzionale): `rete/compose/extrema-ds920/loogle-mcp-compose.yml`
|
||||
4. **Cambio password** per tutti gli utenti dal dashboard
|
||||
5. **`MCP_JWT_SECRET`** — impostare valore robusto in produzione
|
||||
|
||||
---
|
||||
|
||||
## Riferimenti
|
||||
|
||||
- [Guida utenti famiglia](docs/GUIDA-UTENTI.md)
|
||||
- [Onboarding tecnico client AI](docs/ONBOARDING.md)
|
||||
- [Runbook operativo](/home/daniely/rete/ha/RUNBOOK-mcp.md)
|
||||
- [README progetto](README.md)
|
||||
@@ -0,0 +1,209 @@
|
||||
# Token Gitea per Loogle MCP
|
||||
|
||||
## Cosa usare: Personal Access Token (PAT)
|
||||
|
||||
Gitea espone l'API REST su `https://git.loogle.it/api/v1/` con header:
|
||||
|
||||
```
|
||||
Authorization: token <token>
|
||||
```
|
||||
|
||||
Ogni utente Gitea ha i propri repository e permessi. L'hub MCP usa un token **per utente famiglia**, come Paperless.
|
||||
|
||||
---
|
||||
|
||||
## Dove generarlo
|
||||
|
||||
Per ogni utente che deve usare i tool Gitea da Claude/Cursor (daniele, lucia, …):
|
||||
|
||||
1. Accedi a https://git.loogle.it con quell'account
|
||||
2. **Impostazioni** → **Applicazioni** → **Genera nuovo token**
|
||||
3. Nome suggerito: `loogle-mcp`
|
||||
4. Scope minimi:
|
||||
- `read:repository` — list_repos, get_file, search_code, list_issues, get_issue, RAG
|
||||
- `write:repository` — **P4:** create_or_update_file
|
||||
- `write:user` — **P4:** create_gitea_repo (`POST /user/repos`)
|
||||
- `write:issue` — create_issue
|
||||
- `read:user` — consigliato per `/user/repos`
|
||||
5. Copia il token (mostrato una sola volta) in `.env`
|
||||
|
||||
### Alternativa CLI (admin, sul nodo con Gitea attivo)
|
||||
|
||||
```bash
|
||||
docker exec -u git gitea gitea admin user generate-access-token \
|
||||
--username daniele \
|
||||
--token-name loogle-mcp \
|
||||
--scopes 'read:repository,write:repository,write:issue,write:user,read:user'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configurazione in Loogle MCP
|
||||
|
||||
Modifica `/home/daniely/docker/loogle-mcp/.env`:
|
||||
|
||||
```env
|
||||
GITEA_URL=https://git.loogle.it
|
||||
GITEA_API_TOKEN_DANIELE=
|
||||
GITEA_API_TOKEN_LUCIA=
|
||||
GITEA_API_TOKEN_DAVIDE=
|
||||
GITEA_API_TOKEN_LUCA=
|
||||
```
|
||||
|
||||
| Utente MCP | Variabile `.env` | Account Gitea |
|
||||
|------------|------------------|---------------|
|
||||
| Daniele | `GITEA_API_TOKEN_DANIELE` | daniele |
|
||||
| Lucia | `GITEA_API_TOKEN_LUCIA` | lucia |
|
||||
| Davide | `GITEA_API_TOKEN_DAVIDE` | davide |
|
||||
| Luca | `GITEA_API_TOKEN_LUCA` | luca |
|
||||
|
||||
### URL API interno (opzionale)
|
||||
|
||||
Se `https://git.loogle.it` non è raggiungibile dal container MCP (rete LAN, failover), imposta un URL diretto:
|
||||
|
||||
```env
|
||||
GITEA_API_URL=http://192.168.128.81:3002
|
||||
```
|
||||
|
||||
I link nei risultati restano su `GITEA_URL` pubblico.
|
||||
|
||||
### Fallback admin
|
||||
|
||||
```env
|
||||
GITEA_API_TOKEN=<token-admin>
|
||||
```
|
||||
|
||||
Tutti gli utenti MCP useranno lo stesso token (sconsigliato salvo test).
|
||||
|
||||
---
|
||||
|
||||
## Tool MCP Gitea
|
||||
|
||||
| Tool | Scope | Descrizione |
|
||||
|------|-------|-------------|
|
||||
| `list_repos` | gitea:read | Repository accessibili |
|
||||
| `get_file` | gitea:read | Legge file o elenca directory |
|
||||
| `search_code` | gitea:read | Cerca nel codice (API `/search/code` o fallback tree) |
|
||||
| `list_issues` | gitea:read | Issue aperte/chiuse |
|
||||
| `get_issue` | gitea:read | Dettaglio issue |
|
||||
| `create_issue` | gitea:write | Crea issue |
|
||||
| `create_gitea_repo` | gitea:write | Crea repo sotto l'utente MCP corrente |
|
||||
| `create_or_update_file` | gitea:write | Commit singolo di un file (create/update via Contents API) |
|
||||
|
||||
Dopo modifica `.env`:
|
||||
|
||||
```bash
|
||||
cd /home/daniely/docker/loogle-mcp && docker compose up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verifica
|
||||
|
||||
```bash
|
||||
/home/daniely/docker/loogle-mcp/scripts/test_gitea_integration.py
|
||||
```
|
||||
|
||||
Oppure da MCP (con token OAuth): tool `list_repos` e `get_file` su `daniele/rete`.
|
||||
|
||||
---
|
||||
|
||||
## Collegamento progetto ↔ repository (P2)
|
||||
|
||||
Ogni progetto MCP può referenziare un repo Gitea in `meta.json`:
|
||||
|
||||
```json
|
||||
"gitea_repo": "daniele/rete"
|
||||
```
|
||||
|
||||
### Tool
|
||||
|
||||
| Tool | Azione |
|
||||
|------|--------|
|
||||
| `create_project` | Parametri opzionali `gitea_repo`, `seed_from_gitea` |
|
||||
| `link_project_repo` | Collega, cambia repo o scollega (`gitea_repo` vuoto) |
|
||||
| `get_project_context` | Aggiunge blocco `gitea` con README, `docs/` e issue aperte |
|
||||
|
||||
### Seed README
|
||||
|
||||
Con `seed_from_gitea: true` il README del repo viene importato in `context.md` (una sola volta, marker `<!-- seed:gitea ... -->`).
|
||||
|
||||
### Verifica P2
|
||||
|
||||
```bash
|
||||
/home/daniely/docker/loogle-mcp/scripts/test_project_gitea_p2.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RAG su repository Gitea (P3)
|
||||
|
||||
Il worker `loogle-mcp-indexer` indicizza periodicamente file testo dai repo Gitea (`.md`, `.py`, `.sh`, …) in collection Qdrant dedicate:
|
||||
|
||||
| Collection | Contenuto |
|
||||
|------------|-----------|
|
||||
| `gitea_shared_family` | Repo non privati |
|
||||
| `gitea_personal_{user}` | Repo privati dell'owner |
|
||||
|
||||
### Config `.env`
|
||||
|
||||
```env
|
||||
GITEA_INDEX_ENABLED=yes
|
||||
GITEA_INDEX_REPOS_DANIELE=daniele/rete,daniele/loogle-scripts
|
||||
GITEA_INDEX_MAX_FILES_PER_REPO=150
|
||||
GITEA_INDEX_MAX_FILE_BYTES=120000
|
||||
```
|
||||
|
||||
Se `GITEA_INDEX_REPOS_{USER}` è vuoto → tutti i repo accessibili via API.
|
||||
|
||||
### Tool MCP
|
||||
|
||||
| Tool | Descrizione |
|
||||
|------|-------------|
|
||||
| `search_gitea_knowledge` | Ricerca semantica solo su Gitea |
|
||||
| `search_knowledge` | Include Paperless + contesti + Gitea |
|
||||
| `list_gitea_indexed_files` | Stato indicizzazione |
|
||||
| `reindex_gitea_repo` | Forza re-index di un repo |
|
||||
|
||||
### Verifica P3
|
||||
|
||||
```bash
|
||||
docker exec loogle-mcp python3 /srv/scripts/test_gitea_rag_p3.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scrittura su Gitea (P4)
|
||||
|
||||
L'AI può creare repository e committare singoli file via API Gitea (senza git push locale).
|
||||
|
||||
| Tool | Scope | Azione |
|
||||
|------|-------|--------|
|
||||
| `create_gitea_repo` | gitea:write | Crea repo sotto l'utente MCP autenticato |
|
||||
| `create_or_update_file` | gitea:write | Crea o aggiorna un file (Contents API + commit) |
|
||||
|
||||
Regole:
|
||||
|
||||
- Scrittura consentita solo su repo di cui l'utente è **owner** (salvo admin MCP).
|
||||
- Dopo scrittura, opzionalmente re-indicizza il file nel RAG (`reindex: true` default).
|
||||
- Token Gitea: `write:user` (create repo) + `write:repository` (file).
|
||||
|
||||
### Workspace davide e luca
|
||||
|
||||
| Utente | Repo Gitea | Progetto MCP |
|
||||
|--------|------------|--------------|
|
||||
| davide | `davide/progetti` | Progetti Davide |
|
||||
| luca | `luca/progetti` | Progetti Luca |
|
||||
|
||||
Setup iniziale:
|
||||
|
||||
```bash
|
||||
docker exec loogle-mcp python3 /srv/scripts/setup_gitea_p4_repos.py
|
||||
```
|
||||
|
||||
### Verifica P4
|
||||
|
||||
```bash
|
||||
docker exec loogle-mcp python3 /srv/scripts/test_gitea_p4.py
|
||||
```
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# Guida amministratore — Daniele
|
||||
|
||||
Guida operativa per gestire e usare Loogle MCP Hub.
|
||||
|
||||
---
|
||||
|
||||
## Accesso rapido
|
||||
|
||||
| Cosa | URL / comando |
|
||||
|------|----------------|
|
||||
| Dashboard admin | https://mcp.loogle.it/dashboard (login `daniele`) |
|
||||
| Health | `curl -sf https://mcp.loogle.it/health` |
|
||||
| Log gateway | `docker logs -f loogle-mcp` |
|
||||
| Log indexer | `docker logs -f loogle-mcp-indexer` |
|
||||
| Compose | `cd /home/daniely/docker/loogle-mcp` |
|
||||
|
||||
---
|
||||
|
||||
## Configurazione iniziale (checklist)
|
||||
|
||||
- [x] Stack Docker avviato (`docker compose up -d`)
|
||||
- [x] DNS Pi-hole: `mcp.loogle.it` → `192.168.128.85`
|
||||
- [x] NPM proxy host 30 + TLS Let's Encrypt
|
||||
- [ ] Token Paperless in `.env` — vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md) (Profilo utente, non «Applicazione social»)
|
||||
- [ ] `MCP_JWT_SECRET` robusto in `.env`
|
||||
- [ ] Ollama su DS920 con `ollama pull nomic-embed-text`
|
||||
- [ ] Qdrant su DS920 (opzionale, vedi compose extrema)
|
||||
- [ ] Password cambiate per tutta la famiglia
|
||||
- [ ] Ogni familiare ha collegato almeno un client AI
|
||||
|
||||
**Script utili:**
|
||||
```bash
|
||||
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh # DNS + NPM
|
||||
./scripts/deploy.sh # rebuild + restart
|
||||
/home/daniely/rete/scripts/npm-failover-route.sh normal # upstream failover
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Come usare tu (Daniele)
|
||||
|
||||
### Dashboard
|
||||
|
||||
1. https://mcp.loogle.it/dashboard — login `daniele`
|
||||
2. Vedi progetti, audit log completo, revoca refresh token (sezione admin)
|
||||
|
||||
### Con Cursor / Claude / ChatGPT
|
||||
|
||||
Stesso URL MCP: `https://mcp.loogle.it/mcp`
|
||||
|
||||
**Cursor** — aggiungi in `.cursor/mcp.json` del progetto:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"loogle-mcp": {
|
||||
"url": "https://mcp.loogle.it/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Workflow consigliato per un task
|
||||
|
||||
1. **create_project** — es. «Homelab MCP», tag `rete`, `2026`
|
||||
2. Lavori con 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)
|
||||
@@ -0,0 +1,237 @@
|
||||
# Paperless — Guida per la famiglia LOOGLE
|
||||
|
||||
**Archivio documenti di casa:** https://docs.loogle.it
|
||||
|
||||
Questa guida spiega **perché** usiamo Paperless, **come** configurarlo e **come** installare 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.*
|
||||
@@ -0,0 +1,326 @@
|
||||
# Guida utenti — Loogle MCP Hub
|
||||
|
||||
Questa guida è per **Lucia, Davide e Luca** (e chiunque usi gli assistenti AI collegati a casa).
|
||||
|
||||
---
|
||||
|
||||
## Cos’è
|
||||
|
||||
Loogle MCP è la **memoria di casa** per i tuoi assistenti AI. Permette a ChatGPT, Claude o Gemini di:
|
||||
|
||||
- **Ricordare** i tuoi progetti (es. ristrutturazione, studio, hobby)
|
||||
- **Cercare** documenti che abbiamo già archiviato in Paperless (bollette, manuali, PDF)
|
||||
- **Salvare** note e riassunti che 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`.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -0,0 +1,157 @@
|
||||
# Onboarding client AI — Loogle MCP Hub
|
||||
|
||||
Endpoint MCP: `https://mcp.loogle.it/mcp`
|
||||
|
||||
OAuth: al primo collegamento compare la pagina login Loogle. Usa le credenziali famiglia.
|
||||
|
||||
Password iniziale = username (es. `lucia` / `lucia`). Cambiala da https://mcp.loogle.it/dashboard
|
||||
|
||||
---
|
||||
|
||||
## Daniele (admin)
|
||||
|
||||
- Username: `daniele`
|
||||
- Tool admin: `reindex_document`, audit completo, revoca token refresh
|
||||
|
||||
## Lucia
|
||||
|
||||
- Username: `lucia`
|
||||
- Progetti isolati in `/mnt/ha-apps/mcp/context/lucia/`
|
||||
|
||||
## Davide
|
||||
|
||||
- Username: `davide`
|
||||
|
||||
## Luca
|
||||
|
||||
- Username: `luca`
|
||||
|
||||
---
|
||||
|
||||
## ChatGPT (Plus/Pro/Business)
|
||||
|
||||
1. Abilita **Developer Mode** nelle impostazioni ChatGPT
|
||||
2. Settings → Connectors → **Add MCP Connector**
|
||||
3. URL server: `https://mcp.loogle.it/mcp`
|
||||
4. Completa OAuth con le tue credenziali
|
||||
5. Abilita i tool desiderati nella conversazione
|
||||
|
||||
Redirect URI supportati (pre-registrati):
|
||||
- `https://chatgpt.com/connector_platform_oauth_redirect`
|
||||
- `https://chat.openai.com/connector_platform_oauth_redirect`
|
||||
|
||||
## Claude App / Claude Desktop
|
||||
|
||||
1. Settings → Connectors → **Add remote MCP server**
|
||||
2. URL: `https://mcp.loogle.it/mcp`
|
||||
3. Autenticazione OAuth al primo uso
|
||||
|
||||
Redirect URI: `https://claude.ai/api/mcp/auth_callback`
|
||||
|
||||
## Cursor IDE
|
||||
|
||||
Cursor supporta MCP remoto con **OAuth automatico** (Streamable HTTP).
|
||||
|
||||
### Configurazione
|
||||
|
||||
**Globale** (tutti i progetti): `~/.cursor/mcp.json`
|
||||
**Solo questo repo**: `.cursor/mcp.json` (già presente nel progetto loogle-mcp)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"loogle-mcp": {
|
||||
"url": "https://mcp.loogle.it/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Collegamento
|
||||
|
||||
1. Apri **Cursor Settings** → **Tools & MCP**
|
||||
2. Trova **loogle-mcp** → clic **Connect**
|
||||
3. Si apre il browser → login con le **tue** credenziali Loogle (`daniele`, `lucia`, …)
|
||||
4. Autorizza → Cursor torna con i tool attivi
|
||||
|
||||
Redirect OAuth Cursor (già registrati sul server):
|
||||
- `https://www.cursor.com/agents/mcp/oauth/callback` (web / Agents)
|
||||
- `http://localhost:8787/callback` (desktop app)
|
||||
|
||||
### Uso in chat
|
||||
|
||||
Chiedi all'agente di usare i tool MCP, ad esempio:
|
||||
|
||||
> «Usa whoami e list_projects per vedere i miei progetti Loogle»
|
||||
|
||||
Se il server risulta disconnesso: **Reconnect** da Tools & MCP.
|
||||
|
||||
---
|
||||
|
||||
## Gemini CLI
|
||||
|
||||
Aggiungi in `~/.gemini/settings.json` (o equivalente):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"loogle": {
|
||||
"url": "https://mcp.loogle.it/mcp",
|
||||
"transport": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Al primo avvio completa il flow OAuth nel browser.
|
||||
|
||||
## Cursor IDE
|
||||
|
||||
File `.cursor/mcp.json` nel progetto:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"loogle-mcp": {
|
||||
"url": "https://mcp.loogle.it/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool disponibili
|
||||
|
||||
| Tool | Descrizione |
|
||||
|------|-------------|
|
||||
| `ping` | Test connettività |
|
||||
| `whoami` | Utente e scope |
|
||||
| `list_projects` | Progetti utente |
|
||||
| `create_project` | Nuovo archivio contesto |
|
||||
| `get_project_context` | Legge memoria progetto |
|
||||
| `save_context` | Salva/appende memoria |
|
||||
| `search_context` | Ricerca semantica contesti |
|
||||
| `search_knowledge` | RAG su Paperless + contesti + Gitea indicizzati |
|
||||
| `search_gitea_knowledge` | RAG solo su file Gitea (runbook, markdown) |
|
||||
| `list_gitea_indexed_files` | File Gitea nel vector store |
|
||||
| `reindex_gitea_repo` | Re-indicizza un repo (admin/owner) |
|
||||
| `get_document` | Testo documento Paperless |
|
||||
| `list_recent_documents` | Ultimi doc indicizzati |
|
||||
| `reindex_document` | Solo admin |
|
||||
| `list_repos` | Repository Gitea accessibili |
|
||||
| `get_file` | Legge file da repo Gitea |
|
||||
| `search_code` | Cerca nel codice sorgente |
|
||||
| `list_issues` / `get_issue` | Issue su repo Gitea |
|
||||
| `create_issue` | Crea issue (scope gitea:write) |
|
||||
| `link_project_repo` | Collega/scollega repo Gitea a un progetto |
|
||||
|
||||
## Esempio prompt agente
|
||||
|
||||
> "Usa list_projects per vedere i miei progetti, poi get_project_context sul progetto attivo e search_knowledge per trovare documenti rilevanti su [argomento]."
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **401 su MCP**: token scaduto — riconnetti il connector OAuth
|
||||
- **Knowledge vuota**: verifica token Paperless e Ollama su DS920
|
||||
- **Dashboard**: https://mcp.loogle.it/dashboard
|
||||
@@ -0,0 +1,108 @@
|
||||
# Token Paperless per Loogle MCP
|
||||
|
||||
## Cosa NON usare: token «Applicazione social»
|
||||
|
||||
Nell'admin Django di Paperless (`/admin/socialaccount/socialtoken/`) compare il form **«Aggiungi token dell'applicazione social»** con campi Token, Token segreto, Scade il.
|
||||
|
||||
Questi token servono a **django-allauth** per il login OAuth web (Google, Microsoft, ecc.) su Paperless. **Non** sono token dell'API REST di Paperless e **non** vanno usati per Loogle MCP.
|
||||
|
||||
Non devi configurare nulla in quella schermata per il nostro hub MCP.
|
||||
|
||||
---
|
||||
|
||||
## Cosa usare: token API utente
|
||||
|
||||
Paperless espone un'API REST autenticata con header:
|
||||
|
||||
```
|
||||
Authorization: Token <token>
|
||||
```
|
||||
|
||||
Ogni utente Paperless ha il proprio token, legato ai permessi di quell'account (documenti visibili, tag, ecc.).
|
||||
|
||||
### Dove generarlo
|
||||
|
||||
Per **ogni** familiare con account Paperless (daniele, lucia, davide, luca):
|
||||
|
||||
1. Accedi a https://docs.loogle.it con quell'account
|
||||
2. Menu utente (in alto a destra) → **Profilo** / **Il mio profilo**
|
||||
3. Sezione **Token API** → pulsante freccia circolare (rigenera token)
|
||||
4. Copia il token nella riga corrispondente di `.env`
|
||||
|
||||
Alternativa admin: Django admin → **Token** (app `authtoken`), associato all'utente.
|
||||
|
||||
---
|
||||
|
||||
## Configurazione in Loogle MCP
|
||||
|
||||
Modifica `/home/daniely/docker/loogle-mcp/.env`:
|
||||
|
||||
### Opzione consigliata — token per utente (4 familiari)
|
||||
|
||||
```env
|
||||
PAPERLESS_URL=https://docs.loogle.it
|
||||
PAPERLESS_API_TOKEN_DANIELE=
|
||||
PAPERLESS_API_TOKEN_LUCIA=
|
||||
PAPERLESS_API_TOKEN_DAVIDE=
|
||||
PAPERLESS_API_TOKEN_LUCA=
|
||||
```
|
||||
|
||||
| Utente MCP | Variabile `.env` | Account Paperless |
|
||||
|------------|------------------|-------------------|
|
||||
| Daniele | `PAPERLESS_API_TOKEN_DANIELE` | utente admin / superuser |
|
||||
| Lucia | `PAPERLESS_API_TOKEN_LUCIA` | utente Lucia |
|
||||
| Davide | `PAPERLESS_API_TOKEN_DAVIDE` | utente Davide |
|
||||
| Luca | `PAPERLESS_API_TOKEN_LUCA` | utente Luca |
|
||||
|
||||
Righe vuote = utente saltato dall'indexer finché non inserisci il token.
|
||||
|
||||
### Opzione alternativa — un solo token admin
|
||||
|
||||
Se preferisci un unico account Paperless (es. Daniele superuser):
|
||||
|
||||
```env
|
||||
PAPERLESS_API_TOKEN=<token-daniele>
|
||||
```
|
||||
|
||||
Tutti gli utenti MCP useranno quel token in fallback.
|
||||
|
||||
### Opzione C — mappa JSON
|
||||
|
||||
```env
|
||||
PAPERLESS_API_TOKENS={"daniele":"...","lucia":"...","davide":"...","luca":"..."}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comportamento nel sistema
|
||||
|
||||
| Componente | Comportamento |
|
||||
|------------|---------------|
|
||||
| **Indexer** | Usa tutti i token configurati, unisce l'elenco documenti (deduplica per ID) |
|
||||
| **`search_knowledge`** | Cerca su tutto ciò che è stato indicizzato |
|
||||
| **`get_document`** | Usa il token dell'utente MCP loggato; se manca, fallback su `PAPERLESS_API_TOKEN` o token Daniele |
|
||||
|
||||
Con token separati, ogni familiare può scaricare via MCP solo i documenti visibili al proprio account Paperless.
|
||||
|
||||
---
|
||||
|
||||
## Applicare le modifiche
|
||||
|
||||
```bash
|
||||
cd /home/daniely/docker/loogle-mcp
|
||||
# modifica .env con i token reali
|
||||
docker compose restart loogle-mcp loogle-mcp-indexer
|
||||
docker logs loogle-mcp-indexer --tail 30
|
||||
```
|
||||
|
||||
Log atteso: indicizzazione da `daniele`, `lucia`, `davide`, `luca` (solo utenti con token impostato).
|
||||
|
||||
---
|
||||
|
||||
## Checklist onboarding Paperless
|
||||
|
||||
- [ ] Account Paperless creato per daniele, lucia, davide, luca (se non esistono)
|
||||
- [ ] Token API generato per ciascuno (Profilo → Token API)
|
||||
- [ ] Quattro righe compilate in `.env`
|
||||
- [ ] `docker compose restart loogle-mcp loogle-mcp-indexer`
|
||||
- [ ] Log indexer senza errori 401
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.2
|
||||
python-multipart==0.0.20
|
||||
PyJWT==2.10.1
|
||||
cryptography==44.0.2
|
||||
httpx==0.28.1
|
||||
qdrant-client==1.13.3
|
||||
requests==2.32.3
|
||||
apscheduler==3.10.4
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
echo "Attendo health..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:8700/health >/dev/null; then
|
||||
echo "OK — loogle-mcp attivo su :8700"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Health check fallito — vedi docker logs loogle-mcp" >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
mkdir -p /data /data/context
|
||||
|
||||
# Chiave SSH per thermal gate (permessi OpenSSH)
|
||||
if [ -f /run/secrets/ds920_ssh_key ]; then
|
||||
mkdir -p /root/.ssh
|
||||
cp /run/secrets/ds920_ssh_key /root/.ssh/ds920_key
|
||||
chmod 600 /root/.ssh/ds920_key
|
||||
export DS920_SSH_KEY=/root/.ssh/ds920_key
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Probe temperatura/load CPU DS920 — HTTP GET /thermal su :9191.
|
||||
|
||||
Avvio (sul DS920, utente daniely):
|
||||
nohup python3 ds920_thermal_probe.py >> /tmp/thermal-probe.log 2>&1 &
|
||||
|
||||
O via Task Scheduler Synology all'avvio.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
PORT = int(os.environ.get("THERMAL_PROBE_PORT", "9191"))
|
||||
HWMON = os.environ.get("THERMAL_HWMON", "/sys/class/hwmon/hwmon0")
|
||||
|
||||
|
||||
def read_metrics() -> dict:
|
||||
temps = []
|
||||
try:
|
||||
for name in sorted(os.listdir(HWMON)):
|
||||
if name.startswith("temp") and name.endswith("_input"):
|
||||
with open(os.path.join(HWMON, name), encoding="utf-8") as f:
|
||||
temps.append(int(f.read().strip()) / 1000.0)
|
||||
except OSError:
|
||||
pass
|
||||
load1, load5, load15 = os.getloadavg()
|
||||
nproc = os.cpu_count() or 4
|
||||
return {
|
||||
"ok": True,
|
||||
"host": os.uname().nodename,
|
||||
"cpu_temp_c": max(temps) if temps else None,
|
||||
"temps_c": temps,
|
||||
"load1": load1,
|
||||
"load5": load5,
|
||||
"load15": load15,
|
||||
"nproc": nproc,
|
||||
"cpu_target_load": round(nproc * 0.75, 2),
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt: str, *args) -> None: # noqa: A003
|
||||
return
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if self.path.split("?")[0] not in ("/", "/thermal", "/health"):
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
body = json.dumps(read_metrics()).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
||||
print(f"thermal probe listening on 0.0.0.0:{PORT}", flush=True)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,721 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Esporta un blocco giornaliero ricco su progetto MCP (default: homelab-loogle).
|
||||
|
||||
Fonti (massimo contesto utile, non dump grezzo):
|
||||
- transcript agenti Cursor (query utente + conclusioni assistente)
|
||||
- audit_log tool MCP del giorno
|
||||
- commit git recenti in repo tipici (rete, loogle-mcp)
|
||||
- piani Cursor `~/.cursor/plans/*.plan.md` → artifacts/plans/ + indice in context.md
|
||||
|
||||
Idempotente: marker <!-- daily-digest:YYYY-MM-DD --> — con --force sostituisce il blocco del giorno.
|
||||
|
||||
Esempi:
|
||||
python3 scripts/export_daily_agent_digest.py
|
||||
python3 scripts/export_daily_agent_digest.py --date 2026-09-03 --force
|
||||
python3 scripts/export_daily_agent_digest.py --dry-run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
MARKER_RE = re.compile(r"<!--\s*daily-digest:(\d{4}-\d{2}-\d{2})\s*-->")
|
||||
USER_QUERY_RE = re.compile(
|
||||
r"<timestamp>(.*?)</timestamp>\s*<user_query>\s*(.*?)\s*</user_query>",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
TS_PREFIX_RE = re.compile(r"^\[?\d{4}-\d{2}-\d{2}")
|
||||
FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
|
||||
|
||||
DEFAULT_TRANSCRIPT_ROOTS = [
|
||||
Path.home() / ".cursor/projects/home-daniely/agent-transcripts",
|
||||
Path.home() / ".cursor/projects/home-daniely-docker-loogle-mcp/agent-transcripts",
|
||||
]
|
||||
DEFAULT_GIT_REPOS = [
|
||||
Path.home() / "rete",
|
||||
Path.home() / "docker/loogle-mcp",
|
||||
]
|
||||
DEFAULT_PLANS_DIR = Path.home() / ".cursor/plans"
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
env_path = ROOT / ".env"
|
||||
if not env_path.is_file():
|
||||
return
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value.strip().strip("'").strip('"')
|
||||
|
||||
|
||||
def _configure_paths() -> None:
|
||||
if Path("/.dockerenv").is_file() or (Path("/data").is_dir() and os.access("/data", os.W_OK)):
|
||||
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
|
||||
os.environ.setdefault("MCP_CONTEXT_ROOT", "/data/context")
|
||||
os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
|
||||
return
|
||||
os.environ.setdefault("MCP_DB", str(ROOT / "data" / "loogle_mcp.db"))
|
||||
os.environ.setdefault("MCP_CONTEXT_ROOT", "/mnt/ha-apps/mcp/context")
|
||||
os.environ.setdefault("MCP_VECTOR_FALLBACK", str(ROOT / "data" / "vector_fallback.db"))
|
||||
|
||||
|
||||
def _parse_day(s: Optional[str]) -> date:
|
||||
if not s:
|
||||
return date.today()
|
||||
return date.fromisoformat(s)
|
||||
|
||||
|
||||
def _day_bounds(day: date) -> tuple[datetime, datetime]:
|
||||
start = datetime.combine(day, datetime.min.time())
|
||||
end = start + timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
|
||||
def _is_noise_query(q: str) -> bool:
|
||||
q = (q or "").strip()
|
||||
if len(q) < 12:
|
||||
return True
|
||||
low = q.lower()
|
||||
if q.startswith("<") or q.startswith("[REDACTED]"):
|
||||
return True
|
||||
noise_prefixes = (
|
||||
"you are ",
|
||||
"you have access",
|
||||
"start multitasking",
|
||||
"briefly inform the user",
|
||||
"perform any necessary follow-up",
|
||||
"the following task has finished",
|
||||
"<mcp_",
|
||||
"<dynamic_tools>",
|
||||
"<agent_transcripts>",
|
||||
)
|
||||
return any(low.startswith(p) or p in low[:80] for p in noise_prefixes)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
text = re.sub(r"\s+", " ", (text or "").strip())
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _extract_text_blocks(message: Any) -> list[str]:
|
||||
if not isinstance(message, dict):
|
||||
return []
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return [content]
|
||||
out: list[str] = []
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
t = part.get("text") or ""
|
||||
if t and t != "[REDACTED]":
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_event_time(raw: str, file_mtime: float) -> Optional[datetime]:
|
||||
raw = (raw or "").strip()
|
||||
cleaned = re.sub(r"\s*\([^)]*\)\s*$", "", raw).strip()
|
||||
for fmt in (
|
||||
"%A, %b %d, %Y, %I:%M %p",
|
||||
"%A, %B %d, %Y, %I:%M %p",
|
||||
"%Y-%m-%dT%H:%M:%S%z",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
):
|
||||
try:
|
||||
return datetime.strptime(cleaned, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return datetime.fromtimestamp(file_mtime)
|
||||
|
||||
|
||||
def collect_transcripts(day: date, roots: list[Path], max_chats: int = 25) -> list[dict]:
|
||||
start, end = _day_bounds(day)
|
||||
chats: list[dict] = []
|
||||
files: list[Path] = []
|
||||
for root in roots:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for path in root.rglob("*.jsonl"):
|
||||
if "subagents" in path.parts:
|
||||
continue
|
||||
files.append(path)
|
||||
|
||||
for path in sorted(files, key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
mtime = path.stat().st_mtime
|
||||
# quick skip: file entirely older than day-1 or newer handled by content
|
||||
if datetime.fromtimestamp(mtime) < start - timedelta(days=2):
|
||||
continue
|
||||
user_queries: list[str] = []
|
||||
assistant_tails: list[str] = []
|
||||
paths_touched: Counter[str] = Counter()
|
||||
day_hit = False
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
role = obj.get("role")
|
||||
for text in _extract_text_blocks(obj.get("message") or {}):
|
||||
if role == "user":
|
||||
for ts_raw, query in USER_QUERY_RE.findall(text):
|
||||
when = _parse_event_time(ts_raw, mtime)
|
||||
if when and start <= when < end:
|
||||
day_hit = True
|
||||
q = _truncate(query, 280)
|
||||
if not _is_noise_query(q):
|
||||
user_queries.append(q)
|
||||
# bare user text without wrapper — solo se breve messaggio umano
|
||||
if (
|
||||
"<user_query>" not in text
|
||||
and start.timestamp() <= mtime < end.timestamp()
|
||||
and len(text) > 40
|
||||
and not _is_noise_query(text)
|
||||
and "<" not in text[:20]
|
||||
):
|
||||
day_hit = True
|
||||
user_queries.append(_truncate(text, 280))
|
||||
elif role == "assistant":
|
||||
if start.timestamp() <= mtime < end.timestamp() or day_hit:
|
||||
if (
|
||||
len(text) > 80
|
||||
and not text.startswith("[REDACTED]")
|
||||
and not text.startswith("<")
|
||||
and "tool_use" not in text[:40]
|
||||
):
|
||||
# preferisci paragrafi conclusivi (markdown grassetto / verdetto)
|
||||
assistant_tails.append(_truncate(text, 320))
|
||||
# tool paths
|
||||
msg = obj.get("message") or {}
|
||||
content = msg.get("content") if isinstance(msg, dict) else None
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if not isinstance(part, dict) or part.get("type") != "tool_use":
|
||||
continue
|
||||
inp = part.get("input") or {}
|
||||
for key in ("path", "target_notebook", "file_path"):
|
||||
if key in inp and isinstance(inp[key], str):
|
||||
paths_touched[inp[key]] += 1
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if not day_hit and not user_queries:
|
||||
# include if file modified that day and has substance
|
||||
if not (start.timestamp() <= mtime < end.timestamp()):
|
||||
continue
|
||||
if not assistant_tails:
|
||||
continue
|
||||
|
||||
# dedupe queries
|
||||
seen = set()
|
||||
uniq_q = []
|
||||
for q in user_queries:
|
||||
if q not in seen:
|
||||
seen.add(q)
|
||||
uniq_q.append(q)
|
||||
chats.append(
|
||||
{
|
||||
"id": path.parent.name if path.parent.name != "agent-transcripts" else path.stem,
|
||||
"queries": uniq_q[:8],
|
||||
"conclusions": assistant_tails[-3:],
|
||||
"paths": [p for p, _ in paths_touched.most_common(8)],
|
||||
}
|
||||
)
|
||||
if len(chats) >= max_chats:
|
||||
break
|
||||
return chats
|
||||
|
||||
|
||||
def collect_audit(day: date, db_path: Path) -> dict:
|
||||
if not db_path.is_file():
|
||||
return {"tools": [], "total": 0}
|
||||
start = day.isoformat()
|
||||
end = (day + timedelta(days=1)).isoformat()
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"SELECT tool_name, COUNT(*) AS n FROM audit_log"
|
||||
" WHERE created_at >= ? AND created_at < ? AND username=?"
|
||||
" GROUP BY tool_name ORDER BY n DESC",
|
||||
(start, end, "daniele"),
|
||||
).fetchall()
|
||||
samples = conn.execute(
|
||||
"SELECT created_at, tool_name, detail FROM audit_log"
|
||||
" WHERE created_at >= ? AND created_at < ? AND username=?"
|
||||
" ORDER BY id DESC LIMIT 15",
|
||||
(start, end, "daniele"),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return {
|
||||
"tools": [(r["tool_name"], r["n"]) for r in rows],
|
||||
"total": sum(r["n"] for r in rows),
|
||||
"samples": [
|
||||
{
|
||||
"at": r["created_at"],
|
||||
"tool": r["tool_name"],
|
||||
"detail": _truncate(r["detail"] or "", 120),
|
||||
}
|
||||
for r in samples
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def collect_git(day: date, repos: list[Path], limit: int = 12) -> list[str]:
|
||||
since = day.isoformat()
|
||||
until = (day + timedelta(days=1)).isoformat()
|
||||
lines: list[str] = []
|
||||
for repo in repos:
|
||||
if not (repo / ".git").exists():
|
||||
continue
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(repo),
|
||||
"log",
|
||||
f"--since={since}",
|
||||
f"--until={until}",
|
||||
"--pretty=format:%h %s",
|
||||
f"-n{limit}",
|
||||
],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=15,
|
||||
).strip()
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
continue
|
||||
if out:
|
||||
for line in out.splitlines():
|
||||
lines.append(f"{repo.name}: {line}")
|
||||
return lines[:limit]
|
||||
|
||||
|
||||
def _parse_plan_file(path: Path) -> dict:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
name = path.stem
|
||||
overview = ""
|
||||
todos_total = 0
|
||||
todos_done = 0
|
||||
m = FRONTMATTER_RE.match(text)
|
||||
if m:
|
||||
fm = m.group(1)
|
||||
nm = re.search(r"^name:\s*(.+)$", fm, re.M)
|
||||
if nm:
|
||||
name = nm.group(1).strip().strip("\"'")
|
||||
ov = re.search(r"^overview:\s*(.+)$", fm, re.M)
|
||||
if ov:
|
||||
overview = ov.group(1).strip().strip("\"'")
|
||||
# overview può essere su più righe YAML quoted — fallback grezzo
|
||||
if overview.startswith("|") or not overview:
|
||||
ov2 = re.search(r"^overview:\s*[>|]?\s*\n((?:[ \t]+.+\n)+)", fm, re.M)
|
||||
if ov2:
|
||||
overview = " ".join(line.strip() for line in ov2.group(1).splitlines())
|
||||
statuses = re.findall(r"^\s+status:\s*(\w+)", fm, re.M)
|
||||
todos_total = len(statuses)
|
||||
todos_done = sum(1 for s in statuses if s == "completed")
|
||||
mtime = datetime.fromtimestamp(path.stat().st_mtime)
|
||||
return {
|
||||
"file": path.name,
|
||||
"name": name,
|
||||
"overview": _truncate(overview, 220),
|
||||
"todos_total": todos_total,
|
||||
"todos_done": todos_done,
|
||||
"mtime": mtime,
|
||||
"text": text,
|
||||
"path": path,
|
||||
}
|
||||
|
||||
|
||||
def collect_and_sync_plans(
|
||||
day: date,
|
||||
plans_dir: Path,
|
||||
dest_dir: Path,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
"""Copia tutti i .plan.md in artifacts/plans/; ritorna catalogo + aggiornati nel giorno."""
|
||||
start, end = _day_bounds(day)
|
||||
plans: list[dict] = []
|
||||
if plans_dir.is_dir():
|
||||
for path in sorted(plans_dir.glob("*.plan.md")):
|
||||
try:
|
||||
plans.append(_parse_plan_file(path))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
copied = 0
|
||||
updated_today: list[dict] = []
|
||||
if not dry_run:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
for plan in plans:
|
||||
target = dest_dir / plan["file"]
|
||||
shutil.copy2(plan["path"], target)
|
||||
copied += 1
|
||||
if start <= plan["mtime"] < end:
|
||||
updated_today.append(plan)
|
||||
# INDEX.md per lettura umana / futuri tool
|
||||
idx_lines = [
|
||||
"# Piani Cursor (sync)",
|
||||
"",
|
||||
f"_Aggiornato {datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')} "
|
||||
f"da `~/.cursor/plans` → `artifacts/plans/`._",
|
||||
"",
|
||||
]
|
||||
for plan in sorted(plans, key=lambda p: p["mtime"], reverse=True):
|
||||
prog = (
|
||||
f"{plan['todos_done']}/{plan['todos_total']}"
|
||||
if plan["todos_total"]
|
||||
else "?"
|
||||
)
|
||||
idx_lines.append(
|
||||
f"- **{plan['name']}** (`{plan['file']}`, todos {prog}, "
|
||||
f"mtime {plan['mtime'].date().isoformat()})"
|
||||
)
|
||||
if plan["overview"]:
|
||||
idx_lines.append(f" - {plan['overview']}")
|
||||
idx_lines.append("")
|
||||
(dest_dir / "INDEX.md").write_text("\n".join(idx_lines), encoding="utf-8")
|
||||
else:
|
||||
updated_today = [p for p in plans if start <= p["mtime"] < end]
|
||||
|
||||
return {
|
||||
"plans": plans,
|
||||
"copied": copied,
|
||||
"updated_today": updated_today,
|
||||
"dest": str(dest_dir),
|
||||
}
|
||||
|
||||
|
||||
def upsert_plans_index_section(context_md: str, plans: list[dict]) -> str:
|
||||
"""Sezione stabile in context.md (non nel digest giornaliero) con indice piani."""
|
||||
begin = "<!-- BEGIN CURSOR PLANS -->"
|
||||
end = "<!-- END CURSOR PLANS -->"
|
||||
lines = [
|
||||
begin,
|
||||
"## Piani Cursor (indice sync)",
|
||||
"",
|
||||
"_File completi in `artifacts/plans/`. Qui solo indice per `get_project_context`._",
|
||||
"",
|
||||
]
|
||||
if not plans:
|
||||
lines.append("- Nessun piano in `~/.cursor/plans`.")
|
||||
else:
|
||||
for plan in sorted(plans, key=lambda p: p["mtime"], reverse=True):
|
||||
prog = (
|
||||
f"{plan['todos_done']}/{plan['todos_total']} done"
|
||||
if plan["todos_total"]
|
||||
else "n/d"
|
||||
)
|
||||
lines.append(
|
||||
f"- **{plan['name']}** — {prog} — `{plan['file']}` "
|
||||
f"({plan['mtime'].date().isoformat()})"
|
||||
)
|
||||
if plan["overview"]:
|
||||
lines.append(f" - {plan['overview']}")
|
||||
lines.extend(["", end, ""])
|
||||
block = "\n".join(lines)
|
||||
|
||||
if begin in context_md and end in context_md:
|
||||
pattern = re.compile(
|
||||
re.escape(begin) + r".*?" + re.escape(end),
|
||||
re.DOTALL,
|
||||
)
|
||||
return pattern.sub(block.strip(), context_md)
|
||||
# inserisci prima dei daily digests se presenti
|
||||
dig = "<!-- BEGIN DAILY DIGESTS -->"
|
||||
if dig in context_md:
|
||||
head, tail = context_md.split(dig, 1)
|
||||
return head.rstrip() + "\n\n" + block + "\n" + dig + tail
|
||||
return context_md.rstrip() + "\n\n" + block
|
||||
|
||||
|
||||
def build_markdown(
|
||||
day: date,
|
||||
chats: list[dict],
|
||||
audit: dict,
|
||||
git_lines: list[str],
|
||||
project_id: str,
|
||||
plans_updated: Optional[list[dict]] = None,
|
||||
) -> str:
|
||||
lines: list[str] = [
|
||||
f"<!-- daily-digest:{day.isoformat()} -->",
|
||||
f"## Digest agenti {day.isoformat()} — `{project_id}`",
|
||||
"",
|
||||
f"_Generato automaticamente da `export_daily_agent_digest.py` "
|
||||
f"({datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')})._",
|
||||
"",
|
||||
]
|
||||
|
||||
lines.append("### Chat / agenti Cursor")
|
||||
if not chats:
|
||||
lines.append("- Nessun transcript rilevante per questo giorno.")
|
||||
else:
|
||||
lines.append(f"- Sessioni considerate: **{len(chats)}**")
|
||||
for i, chat in enumerate(chats, 1):
|
||||
title = chat["queries"][0] if chat["queries"] else chat["id"]
|
||||
lines.append(f"{i}. **{_truncate(title, 120)}** `[{chat['id'][:8]}]`")
|
||||
for q in chat["queries"][1:4]:
|
||||
lines.append(f" - Q: {_truncate(q, 160)}")
|
||||
for c in chat["conclusions"][-2:]:
|
||||
lines.append(f" - → {_truncate(c, 200)}")
|
||||
if chat["paths"]:
|
||||
short_paths = ", ".join(_truncate(p, 60) for p in chat["paths"][:5])
|
||||
lines.append(f" - File: `{short_paths}`")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Tool MCP usati")
|
||||
if not audit.get("total"):
|
||||
lines.append("- Nessuna chiamata tool in audit_log.")
|
||||
else:
|
||||
lines.append(f"- Totale chiamate: **{audit['total']}**")
|
||||
top = ", ".join(f"`{name}`×{n}" for name, n in audit["tools"][:10])
|
||||
lines.append(f"- Top: {top}")
|
||||
for s in (audit.get("samples") or [])[:8]:
|
||||
lines.append(f" - {s['at']} `{s['tool']}` {s['detail']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Commit git (homelab)")
|
||||
if not git_lines:
|
||||
lines.append("- Nessun commit nel giorno.")
|
||||
else:
|
||||
for g in git_lines:
|
||||
lines.append(f"- `{g}`")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Piani Cursor aggiornati oggi")
|
||||
plans_updated = plans_updated or []
|
||||
if not plans_updated:
|
||||
lines.append("- Nessun `.plan.md` modificato in questa data.")
|
||||
else:
|
||||
for plan in plans_updated:
|
||||
prog = (
|
||||
f"{plan['todos_done']}/{plan['todos_total']}"
|
||||
if plan["todos_total"]
|
||||
else "?"
|
||||
)
|
||||
lines.append(
|
||||
f"- **{plan['name']}** (`artifacts/plans/{plan['file']}`, todos {prog})"
|
||||
)
|
||||
if plan["overview"]:
|
||||
lines.append(f" - {plan['overview']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Per Claude / prossimi agenti")
|
||||
lines.append(
|
||||
"- Usa questo blocco come memoria del giorno; per dettagli codice preferisci "
|
||||
"`search_gitea_knowledge` / `get_file` sui path citati."
|
||||
)
|
||||
lines.append(
|
||||
"- Piani completi: sezione **Piani Cursor** in context.md + file in `artifacts/plans/`."
|
||||
)
|
||||
lines.append(
|
||||
"- Non ripetere setup già conclusi; riparti da decisioni e next step qui sopra."
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def upsert_daily_block(context_md: str, day: date, block: str) -> str:
|
||||
"""Rimuove eventuale blocco del giorno e inserisce il nuovo in cima alla sezione digest."""
|
||||
pattern = re.compile(
|
||||
rf"<!--\s*daily-digest:{day.isoformat()}\s*-->.*?"
|
||||
rf"(?=<!--\s*daily-digest:\d{{4}}-\d{{2}}-\d{{2}}\s*-->|<!--\s*END DAILY DIGESTS\s*-->|\Z)",
|
||||
re.DOTALL,
|
||||
)
|
||||
context_md = pattern.sub("", context_md)
|
||||
|
||||
begin = "<!-- BEGIN DAILY DIGESTS -->"
|
||||
end = "<!-- END DAILY DIGESTS -->"
|
||||
if begin not in context_md:
|
||||
context_md = context_md.rstrip() + f"\n\n{begin}\n\n{end}\n"
|
||||
|
||||
# Assicura END
|
||||
if end not in context_md:
|
||||
context_md = context_md.rstrip() + f"\n\n{end}\n"
|
||||
|
||||
head, rest = context_md.split(begin, 1)
|
||||
# rest inizia dopo BEGIN; togli END temporaneamente dalla porzione digest
|
||||
if end in rest:
|
||||
mid, tail = rest.split(end, 1)
|
||||
else:
|
||||
mid, tail = rest, ""
|
||||
mid = mid.strip()
|
||||
new_mid = block.strip() + ("\n\n" + mid if mid else "")
|
||||
return head.rstrip() + f"\n\n{begin}\n\n{new_mid}\n\n{end}" + tail
|
||||
|
||||
|
||||
def has_daily_block(context_md: str, day: date) -> bool:
|
||||
return f"<!-- daily-digest:{day.isoformat()} -->" in context_md
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Digest giornaliero agenti → MCP context")
|
||||
parser.add_argument("--date", help="YYYY-MM-DD (default: oggi)")
|
||||
parser.add_argument("--user", default="daniele")
|
||||
parser.add_argument("--project", default="homelab-loogle")
|
||||
parser.add_argument("--force", action="store_true", help="Sostituisci blocco del giorno se già presente")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--no-index", action="store_true", help="Non aggiornare Qdrant ctx_*")
|
||||
parser.add_argument("--max-chats", type=int, default=20)
|
||||
parser.add_argument(
|
||||
"--no-plans",
|
||||
action="store_true",
|
||||
help="Non sincronizzare ~/.cursor/plans",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
_load_dotenv()
|
||||
_configure_paths()
|
||||
day = _parse_day(args.date)
|
||||
|
||||
proj = Path(os.environ["MCP_CONTEXT_ROOT"]) / args.user / "projects" / args.project
|
||||
plans_dest = proj / "artifacts" / "plans"
|
||||
|
||||
plans_info: dict = {"plans": [], "copied": 0, "updated_today": [], "dest": str(plans_dest)}
|
||||
if not args.no_plans:
|
||||
plans_info = collect_and_sync_plans(
|
||||
day,
|
||||
DEFAULT_PLANS_DIR,
|
||||
plans_dest,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
chats = collect_transcripts(day, DEFAULT_TRANSCRIPT_ROOTS, max_chats=args.max_chats)
|
||||
audit = collect_audit(day, Path(os.environ["MCP_DB"]))
|
||||
git_lines = collect_git(day, DEFAULT_GIT_REPOS)
|
||||
block = build_markdown(
|
||||
day,
|
||||
chats,
|
||||
audit,
|
||||
git_lines,
|
||||
args.project,
|
||||
plans_updated=plans_info.get("updated_today") or [],
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
print(block)
|
||||
if plans_info.get("plans"):
|
||||
print("\n# plans index preview", file=sys.stderr)
|
||||
for p in plans_info["plans"][:5]:
|
||||
print(f"# - {p['name']} ({p['file']})", file=sys.stderr)
|
||||
print(
|
||||
f"\n# dry-run chats={len(chats)} audit={audit.get('total', 0)} "
|
||||
f"git={len(git_lines)} plans={len(plans_info.get('plans') or [])} "
|
||||
f"plans_today={len(plans_info.get('updated_today') or [])} chars={len(block)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
from app.context import store as context_store
|
||||
from app.db import init_db
|
||||
from app.knowledge import indexer
|
||||
|
||||
init_db()
|
||||
data = context_store.get_project_context(args.user, args.project, session_limit=1, include_gitea=False)
|
||||
existing = data.get("context_md") or ""
|
||||
|
||||
# I piani si sincronizzano sempre; il digest può essere skippato se già presente
|
||||
existing = upsert_plans_index_section(existing, plans_info.get("plans") or [])
|
||||
|
||||
digest_skipped = False
|
||||
if has_daily_block(existing, day) and not args.force:
|
||||
digest_skipped = True
|
||||
new_md = existing
|
||||
print(f"SKIP digest: {day.isoformat()} già presente (usa --force); plans sync ok")
|
||||
else:
|
||||
new_md = upsert_daily_block(existing, day, block)
|
||||
|
||||
ctx_path = proj / "context.md"
|
||||
meta_path = proj / "meta.json"
|
||||
sessions = proj / "sessions"
|
||||
sessions.mkdir(parents=True, exist_ok=True)
|
||||
ctx_path.write_text(new_md, encoding="utf-8")
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
meta["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
snap = None
|
||||
if not digest_skipped:
|
||||
snap = sessions / f"{day.isoformat()}-daily.md"
|
||||
snap.write_text(block, encoding="utf-8")
|
||||
|
||||
if not args.no_index:
|
||||
try:
|
||||
if not digest_skipped:
|
||||
indexer.index_context_snippet(
|
||||
args.user,
|
||||
args.project,
|
||||
block,
|
||||
f"Digest {day.isoformat()} — {meta.get('title', args.project)}",
|
||||
snippet_id=f"daily-{day.isoformat()}",
|
||||
)
|
||||
# indicizza ogni piano (testo ridotto: name+overview+body troncato)
|
||||
for plan in plans_info.get("plans") or []:
|
||||
body = plan["text"]
|
||||
if len(body) > 12000:
|
||||
body = body[:12000] + "\n…[troncato]"
|
||||
indexer.index_context_snippet(
|
||||
args.user,
|
||||
args.project,
|
||||
f"# Piano Cursor: {plan['name']}\n\n{plan['overview']}\n\n{body}",
|
||||
f"Plan: {plan['name']}",
|
||||
snippet_id=f"plan-{plan['file']}",
|
||||
)
|
||||
print("indexed: ok")
|
||||
except Exception as exc:
|
||||
print(f"indexed: skip ({exc})")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"day": day.isoformat(),
|
||||
"project": args.project,
|
||||
"digest_skipped": digest_skipped,
|
||||
"chats": len(chats),
|
||||
"audit_calls": audit.get("total", 0),
|
||||
"git_commits": len(git_lines),
|
||||
"plans_synced": plans_info.get("copied", 0),
|
||||
"plans_updated_today": len(plans_info.get("updated_today") or []),
|
||||
"chars": len(block),
|
||||
"context_chars": len(new_md),
|
||||
"session": str(snap) if snap else None,
|
||||
"plans_dest": plans_info.get("dest"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Grant view permissions on non-personal Paperless documents to family users."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
PAPERLESS_URL = os.environ.get("PAPERLESS_URL", "https://docs.loogle.it").rstrip("/")
|
||||
ADMIN_TOKEN = os.environ.get("PAPERLESS_API_TOKEN_DANIELE") or os.environ.get("PAPERLESS_API_TOKEN", "")
|
||||
FAMILY_USER_IDS = [4, 5, 6] # lucia, davide, luca
|
||||
PERSONAL_TAG_NAMES = {"personal", "privato", "private"}
|
||||
BATCH_SIZE = 50
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not ADMIN_TOKEN:
|
||||
print("Missing PAPERLESS_API_TOKEN_DANIELE", file=sys.stderr)
|
||||
return 1
|
||||
headers = {"Authorization": f"Token {ADMIN_TOKEN}", "Content-Type": "application/json"}
|
||||
|
||||
tags_resp = httpx.get(f"{PAPERLESS_URL}/api/tags/", headers=headers, timeout=60)
|
||||
tags_resp.raise_for_status()
|
||||
personal_tag_ids = {
|
||||
t["id"]
|
||||
for t in tags_resp.json().get("results", [])
|
||||
if (t.get("name") or "").lower() in PERSONAL_TAG_NAMES
|
||||
}
|
||||
|
||||
doc_ids: list[int] = []
|
||||
page = 1
|
||||
while True:
|
||||
resp = httpx.get(
|
||||
f"{PAPERLESS_URL}/api/documents/",
|
||||
headers=headers,
|
||||
params={"page": page, "page_size": 100},
|
||||
timeout=60,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
for doc in data.get("results", []):
|
||||
if set(doc.get("tags") or []) & personal_tag_ids:
|
||||
continue
|
||||
doc_ids.append(doc["id"])
|
||||
if not data.get("next"):
|
||||
break
|
||||
page += 1
|
||||
|
||||
print(f"Documenti da condividere (senza tag personali): {len(doc_ids)}")
|
||||
|
||||
updated = 0
|
||||
for i in range(0, len(doc_ids), BATCH_SIZE):
|
||||
chunk = doc_ids[i : i + BATCH_SIZE]
|
||||
payload = {
|
||||
"documents": chunk,
|
||||
"method": "set_permissions",
|
||||
"parameters": {
|
||||
"set_permissions": {
|
||||
"view": {"users": FAMILY_USER_IDS, "groups": []},
|
||||
"change": {"users": [], "groups": []},
|
||||
},
|
||||
"merge": True,
|
||||
},
|
||||
}
|
||||
resp = httpx.post(
|
||||
f"{PAPERLESS_URL}/api/documents/bulk_edit/",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
updated += len(chunk)
|
||||
print(f" Aggiornati {updated}/{len(doc_ids)}")
|
||||
|
||||
for name, env_key in [
|
||||
("lucia", "PAPERLESS_API_TOKEN_LUCIA"),
|
||||
("davide", "PAPERLESS_API_TOKEN_DAVIDE"),
|
||||
("luca", "PAPERLESS_API_TOKEN_LUCA"),
|
||||
]:
|
||||
token = os.environ.get(env_key, "")
|
||||
if not token:
|
||||
continue
|
||||
count = httpx.get(
|
||||
f"{PAPERLESS_URL}/api/documents/?page_size=1",
|
||||
headers={"Authorization": f"Token {token}"},
|
||||
timeout=30,
|
||||
).json().get("count", 0)
|
||||
print(f"{name}: documenti visibili = {count}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Indicizzazione Gitea one-shot — eseguire con indexer fermo per evitare OOM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
env_path = os.path.join(ROOT, ".env")
|
||||
if not os.path.isfile(env_path):
|
||||
return
|
||||
with open(env_path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
if key.strip() and key.strip() not in os.environ:
|
||||
os.environ[key.strip()] = value.strip().strip("'").strip('"')
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Indicizza repo Gitea nel vector store")
|
||||
parser.add_argument("--repo", help="owner/name (default: tutti i repo configurati)")
|
||||
parser.add_argument("--user", default="daniele", help="Utente MCP/Gitea token")
|
||||
parser.add_argument("--max-files", type=int, default=0, help="Limite file per repo (0=config)")
|
||||
parser.add_argument("--force", action="store_true", help="Re-indicizza anche file invariati")
|
||||
args = parser.parse_args()
|
||||
|
||||
_load_dotenv()
|
||||
if os.path.isfile("/.dockerenv"):
|
||||
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
|
||||
os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
|
||||
|
||||
from app.db import init_db
|
||||
from app.knowledge import gitea_indexer
|
||||
|
||||
init_db()
|
||||
max_files = args.max_files or None
|
||||
|
||||
if args.repo:
|
||||
result = gitea_indexer.index_repo(
|
||||
args.repo,
|
||||
username=args.user,
|
||||
force=args.force,
|
||||
max_files=max_files,
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
result = gitea_indexer.index_all(max_files_per_repo=max_files)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
stats = gitea_indexer.index_stats()
|
||||
print("stats", json.dumps(stats, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Crea un progetto demo per ogni utente famiglia."""
|
||||
import sys
|
||||
sys.path.insert(0, "/srv")
|
||||
from app.context import store as context_store
|
||||
|
||||
USERS = {
|
||||
"daniele": "Homelab LOOGLE",
|
||||
"lucia": "Progetti personali",
|
||||
"davide": "Studio e task",
|
||||
"luca": "Progetti Luca",
|
||||
}
|
||||
|
||||
for username, title in USERS.items():
|
||||
root = context_store._user_root(username)
|
||||
existing = context_store.list_projects(username)
|
||||
if existing:
|
||||
print(f"{username}: skip ({len(existing)} progetti)")
|
||||
continue
|
||||
meta = context_store.create_project(username, title, tags=["demo"])
|
||||
context_store.save_context(
|
||||
username,
|
||||
meta["id"],
|
||||
f"Progetto demo iniziale per {username}. Usa save_context per ampliare la memoria agente.",
|
||||
)
|
||||
print(f"{username}: creato {meta['id']}")
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Crea repo progetti per davide/luca e progetti Loogle collegati."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
path = os.path.join(ROOT, ".env")
|
||||
if not os.path.isfile(path):
|
||||
return
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, _, v = line.partition("=")
|
||||
os.environ[k.strip()] = v.strip().strip("'").strip('"')
|
||||
|
||||
|
||||
def ensure_repo(user: str, repo_name: str, description: str) -> str:
|
||||
from app.knowledge import gitea
|
||||
|
||||
gitea._tokens_cache = None
|
||||
full = f"{user}/{repo_name}"
|
||||
existing = {r["full_name"] for r in gitea.list_repos(username=user)["repos"]}
|
||||
if full in existing:
|
||||
print(f"repo_exists: {full}")
|
||||
return full
|
||||
created = gitea.create_repo(
|
||||
repo_name,
|
||||
username=user,
|
||||
private=True,
|
||||
description=description,
|
||||
auto_init=True,
|
||||
)
|
||||
print(f"repo_created: {created['full_name']}")
|
||||
return created["full_name"]
|
||||
|
||||
|
||||
def ensure_mcp_project(user: str, project_title: str, gitea_repo: str) -> None:
|
||||
from app.context import store as context_store
|
||||
|
||||
projects = {p["id"]: p for p in context_store.list_projects(user, include_archived=True)}
|
||||
for meta in projects.values():
|
||||
if meta.get("gitea_repo") == gitea_repo:
|
||||
print(f"project_linked: {user}/{meta['id']} -> {gitea_repo}")
|
||||
return
|
||||
slug = project_title.lower().replace(" ", "-")
|
||||
if slug in projects:
|
||||
meta = context_store.link_project_repo(user, slug, gitea_repo=gitea_repo, seed_from_gitea=True)
|
||||
print(f"project_updated: {user}/{meta['id']} -> {gitea_repo}")
|
||||
return
|
||||
meta = context_store.create_project(
|
||||
user,
|
||||
project_title,
|
||||
tags=["gitea", "p4"],
|
||||
gitea_repo=gitea_repo,
|
||||
seed_from_gitea=True,
|
||||
)
|
||||
print(f"project_created: {user}/{meta['id']} -> {gitea_repo}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_load_dotenv()
|
||||
os.environ.setdefault("MCP_DB", os.environ.get("MCP_DB", "/data/loogle_mcp.db"))
|
||||
from app.db import init_db
|
||||
|
||||
init_db()
|
||||
|
||||
ensure_repo(
|
||||
"davide",
|
||||
"progetti",
|
||||
"Workspace personale — codice e note generate con Loogle MCP",
|
||||
)
|
||||
ensure_repo(
|
||||
"luca",
|
||||
"progetti",
|
||||
"Workspace personale — codice e note generate con Loogle MCP",
|
||||
)
|
||||
|
||||
ensure_mcp_project("davide", "Progetti Davide", "davide/progetti")
|
||||
ensure_mcp_project("luca", "Progetti Luca", "luca/progetti")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test integrazione Gitea MCP — mock server + opzionale live API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
MOCK_TOKEN = "test-gitea-token"
|
||||
MOCK_PORT = 18799
|
||||
|
||||
os.environ["GITEA_URL"] = "https://git.loogle.it"
|
||||
os.environ["GITEA_API_URL"] = f"http://127.0.0.1:{MOCK_PORT}"
|
||||
os.environ["GITEA_API_TOKEN_DANIELE"] = MOCK_TOKEN
|
||||
os.environ["MCP_DB"] = "/tmp/loogle_mcp_test.db"
|
||||
|
||||
|
||||
class GiteaMockHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
|
||||
def _auth_ok(self) -> bool:
|
||||
auth = self.headers.get("Authorization", "")
|
||||
return auth == f"token {MOCK_TOKEN}"
|
||||
|
||||
def _json(self, code: int, payload):
|
||||
body = json.dumps(payload).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if not self._auth_ok():
|
||||
self._json(401, {"message": "invalid token"})
|
||||
return
|
||||
path = urlparse(self.path).path
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
|
||||
if path == "/api/v1/user/repos":
|
||||
self._json(200, [
|
||||
{
|
||||
"full_name": "daniele/rete",
|
||||
"description": "Infra HA",
|
||||
"private": True,
|
||||
"html_url": "https://git.loogle.it/daniele/rete",
|
||||
"default_branch": "main",
|
||||
"updated_at": "2026-08-22T10:00:00Z",
|
||||
}
|
||||
])
|
||||
return
|
||||
|
||||
if path == "/api/v1/repos/daniele/rete/contents/ha/RUNBOOK-failover.md":
|
||||
content = "# Failover tier-b\n\nloogle-mcp nel pivot tier-b.\n"
|
||||
import base64
|
||||
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"type": "file",
|
||||
"path": "ha/RUNBOOK-failover.md",
|
||||
"content": base64.b64encode(content.encode()).decode(),
|
||||
"encoding": "base64",
|
||||
"size": len(content),
|
||||
"sha": "abc123",
|
||||
"html_url": "https://git.loogle.it/daniele/rete/src/branch/main/ha/RUNBOOK-failover.md",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if path == "/api/v1/search/code":
|
||||
q = (qs.get("q") or [""])[0]
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"repository": {"full_name": "daniele/rete"},
|
||||
"path": "ha/RUNBOOK-failover.md",
|
||||
"sha": "abc123",
|
||||
"content": f"...{q}...",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if path == "/api/v1/repos/daniele/rete/issues":
|
||||
self._json(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Test issue",
|
||||
"state": "open",
|
||||
"user": {"login": "daniele"},
|
||||
"html_url": "https://git.loogle.it/daniele/rete/issues/1",
|
||||
"created_at": "2026-08-22T10:00:00Z",
|
||||
"updated_at": "2026-08-22T10:00:00Z",
|
||||
"labels": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
return
|
||||
|
||||
if path == "/api/v1/repos/daniele/rete/issues/1":
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Test issue",
|
||||
"state": "open",
|
||||
"body": "Corpo issue di test",
|
||||
"user": {"login": "daniele"},
|
||||
"html_url": "https://git.loogle.it/daniele/rete/issues/1",
|
||||
"created_at": "2026-08-22T10:00:00Z",
|
||||
"updated_at": "2026-08-22T10:00:00Z",
|
||||
"labels": [],
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
self._json(404, {"message": f"not found: {path}"})
|
||||
|
||||
def do_POST(self):
|
||||
if not self._auth_ok():
|
||||
self._json(401, {"message": "invalid token"})
|
||||
return
|
||||
path = urlparse(self.path).path
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length).decode() or "{}")
|
||||
if path == "/api/v1/repos/daniele/rete/issues":
|
||||
self._json(
|
||||
201,
|
||||
{
|
||||
"number": 42,
|
||||
"title": body.get("title"),
|
||||
"state": "open",
|
||||
"html_url": "https://git.loogle.it/daniele/rete/issues/42",
|
||||
},
|
||||
)
|
||||
return
|
||||
self._json(404, {"message": f"not found: {path}"})
|
||||
|
||||
|
||||
def run_mock_tests() -> None:
|
||||
from app.db import init_db
|
||||
from app.knowledge import gitea as gitea_mod
|
||||
from app.mcp import tools
|
||||
|
||||
init_db()
|
||||
gitea_mod._tokens_cache = None
|
||||
|
||||
server = HTTPServer(("127.0.0.1", MOCK_PORT), GiteaMockHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
claims = {"sub": "daniele", "scope": "gitea:read gitea:write"}
|
||||
|
||||
repos = tools.call_tool("list_repos", {}, claims)
|
||||
assert "daniele/rete" in repos["content"][0]["text"]
|
||||
|
||||
file_data = tools.call_tool(
|
||||
"get_file",
|
||||
{"repo": "daniele/rete", "path": "ha/RUNBOOK-failover.md"},
|
||||
claims,
|
||||
)
|
||||
assert "tier-b" in file_data["content"][0]["text"]
|
||||
|
||||
search = tools.call_tool(
|
||||
"search_code",
|
||||
{"query": "tier-b", "repo": "daniele/rete"},
|
||||
claims,
|
||||
)
|
||||
assert "RUNBOOK-failover" in search["content"][0]["text"]
|
||||
|
||||
issues = tools.call_tool("list_issues", {"repo": "daniele/rete"}, claims)
|
||||
assert "Test issue" in issues["content"][0]["text"]
|
||||
|
||||
issue = tools.call_tool(
|
||||
"get_issue",
|
||||
{"repo": "daniele/rete", "number": 1},
|
||||
claims,
|
||||
)
|
||||
assert "Corpo issue" in issue["content"][0]["text"]
|
||||
|
||||
created = tools.call_tool(
|
||||
"create_issue",
|
||||
{"repo": "daniele/rete", "title": "Da MCP", "body": "Test"},
|
||||
claims,
|
||||
)
|
||||
assert "42" in created["content"][0]["text"]
|
||||
|
||||
server.shutdown()
|
||||
print("OK mock: list_repos, get_file, search_code, list_issues, get_issue, create_issue")
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
env_path = os.path.join(ROOT, ".env")
|
||||
if not os.path.isfile(env_path):
|
||||
return
|
||||
with open(env_path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip("'").strip('"')
|
||||
if key.startswith("GITEA_"):
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def run_live_tests() -> None:
|
||||
_load_dotenv()
|
||||
from app.knowledge import gitea as gitea_mod
|
||||
from app.mcp import tools
|
||||
|
||||
token = os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip()
|
||||
if not token or token == MOCK_TOKEN:
|
||||
print("SKIP live: GITEA_API_TOKEN_DANIELE non impostato (token reale in .env)")
|
||||
return
|
||||
|
||||
api_url = gitea_mod.api_base_url()
|
||||
if str(MOCK_PORT) in api_url:
|
||||
print("SKIP live: GITEA_API_URL punta al mock")
|
||||
return
|
||||
|
||||
gitea_mod._tokens_cache = None
|
||||
claims = {"sub": "daniele", "scope": "gitea:read gitea:write"}
|
||||
|
||||
repos = gitea_mod.list_repos(username="daniele")
|
||||
if not repos.get("repos"):
|
||||
raise RuntimeError("live list_repos: nessun repository")
|
||||
|
||||
first = repos["repos"][0]["full_name"]
|
||||
tools.call_tool("list_repos", {}, claims)
|
||||
print(f"OK live list_repos: {len(repos['repos'])} repo, primo={first}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
run_mock_tests()
|
||||
run_live_tests()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test P4: scrittura Gitea (create repo, create/update file)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
path = os.path.join(ROOT, ".env")
|
||||
if not os.path.isfile(path):
|
||||
return
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, _, v = line.partition("=")
|
||||
os.environ[k.strip()] = v.strip().strip("'").strip('"')
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_load_dotenv()
|
||||
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
|
||||
|
||||
from app.db import init_db
|
||||
from app.knowledge import gitea
|
||||
from app.mcp import tools
|
||||
|
||||
init_db()
|
||||
gitea._tokens_cache = None
|
||||
|
||||
if not os.environ.get("GITEA_API_TOKEN_DANIELE"):
|
||||
print("ERR: token daniele assente")
|
||||
return 1
|
||||
|
||||
claims = {"sub": "daniele", "scope": "gitea:read gitea:write knowledge:read admin"}
|
||||
|
||||
test_repo = "daniele/mcp-p4-test"
|
||||
test_path = "docs/p4-verify.md"
|
||||
test_content = "# P4 verify\n\nFile creato da test_gitea_p4.py\n"
|
||||
|
||||
# cleanup file if exists from prior run
|
||||
try:
|
||||
gitea.get_file(test_repo, test_path, username="daniele")
|
||||
gitea.create_or_update_file(
|
||||
test_repo,
|
||||
test_path,
|
||||
test_content + "\n(updated)\n",
|
||||
"test p4 update",
|
||||
username="daniele",
|
||||
)
|
||||
action = "update"
|
||||
except FileNotFoundError:
|
||||
# ensure repo exists - use temp repo name under daniele
|
||||
repos = {r["full_name"] for r in gitea.list_repos(username="daniele")["repos"]}
|
||||
if test_repo not in repos:
|
||||
created = tools.call_tool(
|
||||
"create_gitea_repo",
|
||||
{
|
||||
"name": "mcp-p4-test",
|
||||
"private": True,
|
||||
"description": "Repo temporaneo test P4",
|
||||
},
|
||||
claims,
|
||||
)
|
||||
print("create_repo", created["content"][0]["text"][:200])
|
||||
written = tools.call_tool(
|
||||
"create_or_update_file",
|
||||
{
|
||||
"repo": test_repo,
|
||||
"path": test_path,
|
||||
"content": test_content,
|
||||
"message": "test p4 create",
|
||||
"reindex": False,
|
||||
},
|
||||
claims,
|
||||
)
|
||||
payload = json.loads(written["content"][0]["text"])
|
||||
action = payload.get("action")
|
||||
print("write", action, payload.get("path"))
|
||||
|
||||
read = gitea.get_file(test_repo, test_path, username="daniele")
|
||||
assert "P4 verify" in read.get("content", ""), read
|
||||
print("read_ok", read["path"])
|
||||
|
||||
for user, repo in (("davide", "davide/progetti"), ("luca", "luca/progetti")):
|
||||
token_key = f"GITEA_API_TOKEN_{user.upper()}"
|
||||
if not os.environ.get(token_key):
|
||||
print(f"SKIP {user}: no token")
|
||||
continue
|
||||
gitea._tokens_cache = None
|
||||
listed = gitea.list_repos(username=user)
|
||||
names = {r["full_name"] for r in listed["repos"]}
|
||||
if repo not in names:
|
||||
raise RuntimeError(f"repo mancante per {user}: {repo}")
|
||||
print(f"{user}_repo_ok", repo)
|
||||
|
||||
print("OK P4: create_or_update_file + repos davide/luca/progetti")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test P3: RAG semantico su file Gitea indicizzati."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
env_path = os.path.join(ROOT, ".env")
|
||||
if not os.path.isfile(env_path):
|
||||
return
|
||||
with open(env_path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value.strip().strip("'").strip('"')
|
||||
|
||||
|
||||
def _configure_paths() -> str:
|
||||
in_container = os.path.isfile("/.dockerenv") or (
|
||||
os.path.isdir("/data") and os.access("/data", os.W_OK)
|
||||
)
|
||||
if in_container:
|
||||
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
|
||||
os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
|
||||
return "container"
|
||||
os.environ["MCP_DB"] = "/tmp/loogle_mcp_p3_test.db"
|
||||
os.environ["MCP_VECTOR_FALLBACK"] = "/tmp/loogle_mcp_p3_vectors.db"
|
||||
return "host"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_load_dotenv()
|
||||
mode = _configure_paths()
|
||||
print(f"mode={mode} db={os.environ['MCP_DB']} vector={os.environ['MCP_VECTOR_FALLBACK']}")
|
||||
|
||||
from app.db import get_conn, init_db
|
||||
from app.knowledge import gitea_indexer
|
||||
from app.mcp import tools
|
||||
|
||||
init_db()
|
||||
if not get_conn().execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'"
|
||||
).fetchone():
|
||||
raise RuntimeError("tabella indexed_gitea_files assente")
|
||||
|
||||
if not os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip():
|
||||
print("SKIP: GITEA_API_TOKEN_DANIELE assente")
|
||||
return 1
|
||||
|
||||
repo = "daniele/rete"
|
||||
existing = gitea_indexer.list_indexed_files(limit=5, repo=repo)
|
||||
stats = gitea_indexer.index_stats()
|
||||
if len(existing) < 1 or stats.get("qdrant_points", 0) < 1:
|
||||
index_result = gitea_indexer.index_repo(
|
||||
repo,
|
||||
username="daniele",
|
||||
force=True,
|
||||
max_files=1,
|
||||
)
|
||||
print("index", json.dumps(index_result, ensure_ascii=False))
|
||||
else:
|
||||
print("index_skip", "already", len(existing), "files")
|
||||
|
||||
files = gitea_indexer.list_indexed_files(limit=5, repo=repo)
|
||||
if not files:
|
||||
raise RuntimeError("indexed_gitea_files vuota")
|
||||
print("indexed_sample", [f["path"] for f in files[:3]])
|
||||
|
||||
claims = {"sub": "daniele", "scope": "knowledge:read gitea:read admin", "admin": "admin"}
|
||||
for query in ("failover tier-b", "runbook"):
|
||||
hits = gitea_indexer.search_gitea_knowledge("daniele", query, limit=5, is_admin=True)
|
||||
if not hits:
|
||||
raise RuntimeError(f"search_gitea_knowledge senza risultati per: {query}")
|
||||
top = hits[0]
|
||||
print(
|
||||
f"search_ok[{query}]",
|
||||
top.get("path") or top.get("title"),
|
||||
round(float(top.get("score", 0)), 3),
|
||||
)
|
||||
|
||||
tool_out = tools.call_tool(
|
||||
"search_gitea_knowledge",
|
||||
{"query": "failover", "limit": 5},
|
||||
claims,
|
||||
)
|
||||
if not json.loads(tool_out["content"][0]["text"]).get("results"):
|
||||
raise RuntimeError("tool search_gitea_knowledge vuoto")
|
||||
|
||||
unified = tools.call_tool(
|
||||
"search_knowledge",
|
||||
{"query": "FAILOVER CENSIMENTO documenti servizi", "limit": 12},
|
||||
claims,
|
||||
)
|
||||
unified_payload = json.loads(unified["content"][0]["text"])
|
||||
results = unified_payload.get("results") or []
|
||||
sources = set()
|
||||
for r in results:
|
||||
if r.get("repo"):
|
||||
sources.add("gitea")
|
||||
elif r.get("source"):
|
||||
sources.add(r["source"])
|
||||
else:
|
||||
sources.add("paperless")
|
||||
print("search_knowledge_sources", sorted(sources))
|
||||
if "gitea" not in sources:
|
||||
raise RuntimeError("search_knowledge non include risultati Gitea")
|
||||
|
||||
list_tool = tools.call_tool("list_gitea_indexed_files", {"repo": repo, "limit": 5}, claims)
|
||||
if json.loads(list_tool["content"][0]["text"]).get("count", 0) <= 0:
|
||||
raise RuntimeError("list_gitea_indexed_files vuoto")
|
||||
|
||||
print("OK P3: search + unified + list (reindex opzionale via tool reindex_gitea_repo)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Verifica P5: tool live Loogle Casa + Home Assistant."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from app.integrations import casa, homeassistant
|
||||
from app.mcp import tools
|
||||
|
||||
user = os.environ.get("MCP_TEST_USER", "daniele")
|
||||
claims = {
|
||||
"sub": user,
|
||||
"scope": "home:read context:read knowledge:read gitea:read admin",
|
||||
}
|
||||
errors = []
|
||||
|
||||
print("=== P5 integrations ===")
|
||||
print("casa configured:", casa.is_configured(user))
|
||||
print("ha configured:", homeassistant.is_configured())
|
||||
|
||||
if casa.is_configured(user):
|
||||
for tool, args in (
|
||||
("get_home_dashboard", {}),
|
||||
("get_home_weather", {}),
|
||||
("get_network_overview", {}),
|
||||
("get_network_failover_status", {}),
|
||||
):
|
||||
try:
|
||||
out = tools.call_tool(tool, args, claims)
|
||||
text = out["content"][0]["text"]
|
||||
data = json.loads(text)
|
||||
print(f"OK {tool}: keys={list(data.keys())[:8]}")
|
||||
except Exception as exc:
|
||||
print(f"FAIL {tool}: {exc}")
|
||||
errors.append(tool)
|
||||
else:
|
||||
errors.append("casa-not-configured")
|
||||
|
||||
if homeassistant.is_configured():
|
||||
try:
|
||||
cfg = homeassistant.get_config()
|
||||
print(f"OK ha config: version={cfg.get('version')}")
|
||||
except Exception as exc:
|
||||
print(f"FAIL ha config: {exc}")
|
||||
errors.append("ha-config")
|
||||
try:
|
||||
out = tools.call_tool("list_ha_entities", {"domain": "switch", "limit": 5}, claims)
|
||||
data = json.loads(out["content"][0]["text"])
|
||||
print(f"OK list_ha_entities: count={len(data.get('entities', []))}")
|
||||
except Exception as exc:
|
||||
print(f"FAIL list_ha_entities: {exc}")
|
||||
errors.append("list_ha_entities")
|
||||
else:
|
||||
errors.append("ha-not-configured")
|
||||
|
||||
if errors:
|
||||
print("P5 FAILED:", errors)
|
||||
return 1
|
||||
print("P5 OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Verifica P6: tool live Irrigazione + Turni."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from app.integrations import irrigazione, turni
|
||||
from app.mcp import tools
|
||||
|
||||
user = os.environ.get("MCP_TEST_USER", "daniele")
|
||||
claims = {
|
||||
"sub": user,
|
||||
"scope": "irrigation:read turni:read home:read admin",
|
||||
}
|
||||
errors = []
|
||||
|
||||
print("=== P6 integrations ===")
|
||||
print("irrigazione configured:", irrigazione.is_configured(user))
|
||||
print("turni configured:", turni.is_configured(user))
|
||||
|
||||
if irrigazione.is_configured(user):
|
||||
for tool, args in (
|
||||
("get_irrigation_status", {}),
|
||||
("get_irrigation_zones", {}),
|
||||
("get_irrigation_history", {"limit": 5}),
|
||||
):
|
||||
try:
|
||||
out = tools.call_tool(tool, args, claims)
|
||||
data = json.loads(out["content"][0]["text"])
|
||||
print(f"OK {tool}: type={type(data).__name__}")
|
||||
except Exception as exc:
|
||||
print(f"FAIL {tool}: {exc}")
|
||||
errors.append(tool)
|
||||
else:
|
||||
errors.append("irrigazione-not-configured")
|
||||
|
||||
try:
|
||||
out = tools.call_tool("get_turni_status", {}, claims)
|
||||
data = json.loads(out["content"][0]["text"])
|
||||
print(f"OK get_turni_status: build={data.get('buildVersion', '')[:30]}")
|
||||
except Exception as exc:
|
||||
print(f"FAIL get_turni_status: {exc}")
|
||||
errors.append("get_turni_status")
|
||||
|
||||
if turni.is_configured(user):
|
||||
for tool, args in (
|
||||
("list_turni_doctors", {}),
|
||||
("get_my_shifts", {"limit": 10}),
|
||||
):
|
||||
try:
|
||||
out = tools.call_tool(tool, args, claims)
|
||||
data = json.loads(out["content"][0]["text"])
|
||||
print(f"OK {tool}: keys={list(data.keys())[:6]}")
|
||||
except Exception as exc:
|
||||
print(f"FAIL {tool}: {exc}")
|
||||
errors.append(tool)
|
||||
else:
|
||||
errors.append("turni-not-configured")
|
||||
|
||||
if errors:
|
||||
print("P6 FAILED:", errors)
|
||||
return 1
|
||||
print("P6 OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Verifica P7: RAG Irrigazione + Turni."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from app.db import init_db
|
||||
from app.knowledge import apps_indexer, indexer
|
||||
from app.mcp import tools
|
||||
|
||||
init_db()
|
||||
user = os.environ.get("MCP_TEST_USER", "daniele")
|
||||
claims = {
|
||||
"sub": user,
|
||||
"scope": "knowledge:read irrigation:read turni:read admin",
|
||||
}
|
||||
errors = []
|
||||
|
||||
print("=== P7 apps RAG ===")
|
||||
existing = apps_indexer.list_indexed_records(limit=5)
|
||||
if len(existing) >= 2:
|
||||
print(f"skip re-index: {len(existing)} records already present")
|
||||
result = {"irrigazione": {"indexed": len(existing), "skipped_reindex": True}, "turni": {}}
|
||||
else:
|
||||
result = {
|
||||
"irrigazione": apps_indexer.index_irrigazione(history_limit=0, events_limit=0),
|
||||
"turni": apps_indexer.index_turni(assignments_limit=3),
|
||||
}
|
||||
print("index_all:", json.dumps(result, ensure_ascii=False)[:500])
|
||||
|
||||
irr = result.get("irrigazione", {})
|
||||
turn = result.get("turni", {})
|
||||
if irr.get("skipped") and turn.get("skipped"):
|
||||
print("P7 FAILED: both sources skipped")
|
||||
return 1
|
||||
if irr.get("indexed", 0) + turn.get("indexed", 0) < 2 and not irr.get("skipped_reindex"):
|
||||
errors.append("insufficient-indexed-records")
|
||||
|
||||
records = apps_indexer.list_indexed_records(limit=10)
|
||||
print(f"indexed records: {len(records)}")
|
||||
if not records:
|
||||
errors.append("no-records")
|
||||
|
||||
try:
|
||||
hits = apps_indexer.search_apps_knowledge("irrigazione zona prato", limit=3)
|
||||
print(f"search_apps_knowledge irrigazione: {len(hits)} hits")
|
||||
if not hits:
|
||||
errors.append("search-irrigazione-empty")
|
||||
except Exception as exc:
|
||||
print(f"FAIL search_apps: {exc}")
|
||||
errors.append("search_apps")
|
||||
|
||||
try:
|
||||
out = tools.call_tool("search_apps_knowledge", {"query": "turno guardia", "limit": 3}, claims)
|
||||
data = json.loads(out["content"][0]["text"])
|
||||
print(f"OK tool search_apps_knowledge: {len(data.get('results', []))} hits")
|
||||
except Exception as exc:
|
||||
print(f"FAIL tool search_apps_knowledge: {exc}")
|
||||
errors.append("tool-search")
|
||||
|
||||
try:
|
||||
combined = indexer.search_knowledge(user, "irrigazione valvola", limit=5, is_admin=True)
|
||||
apps_hits = [h for h in combined if h.get("source") in ("irrigazione", "turni")]
|
||||
print(f"search_knowledge includes apps: {len(apps_hits)} app hits / {len(combined)} total")
|
||||
except Exception as exc:
|
||||
print(f"FAIL search_knowledge: {exc}")
|
||||
errors.append("search_knowledge")
|
||||
|
||||
if errors:
|
||||
print("P7 FAILED:", errors)
|
||||
return 1
|
||||
print("P7 OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
from app.db import init_db
|
||||
from app.knowledge.apps_indexer import (
|
||||
_summarize_irrigation_status,
|
||||
_index_text,
|
||||
list_indexed_records,
|
||||
search_apps_knowledge,
|
||||
)
|
||||
from app.integrations import irrigazione
|
||||
|
||||
init_db()
|
||||
print("1 db ok")
|
||||
existing = list_indexed_records(source="irrigazione", limit=1)
|
||||
if existing and existing[0].get("record_id") == "status-snapshot":
|
||||
print("2 skip index (status-snapshot exists)")
|
||||
else:
|
||||
s = irrigazione.get_status("daniele")
|
||||
print("3 status ok", len(s))
|
||||
text = _summarize_irrigation_status(s)
|
||||
print("4 summary", len(text))
|
||||
r = _index_text("irrigazione", "status-snapshot", "Stato", text)
|
||||
print("5 indexed", r)
|
||||
hits = search_apps_knowledge("irrigazione zona", limit=2)
|
||||
print("6 hits", len(hits))
|
||||
print("STEP OK")
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test P2: collegamento progetti MCP ↔ repository Gitea."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
os.environ["MCP_DB"] = "/tmp/loogle_mcp_p2_test.db"
|
||||
TEST_ROOT = tempfile.mkdtemp(prefix="mcp-p2-")
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
env_path = os.path.join(ROOT, ".env")
|
||||
if not os.path.isfile(env_path):
|
||||
return
|
||||
with open(env_path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), value.strip().strip("'").strip('"'))
|
||||
|
||||
|
||||
def run_unit_tests() -> None:
|
||||
os.environ["MCP_CONTEXT_ROOT"] = TEST_ROOT
|
||||
from app.db import init_db
|
||||
from app.context import store as context_store
|
||||
from app.mcp import tools
|
||||
|
||||
init_db()
|
||||
claims = {
|
||||
"sub": "daniele",
|
||||
"scope": "context:read context:write gitea:read gitea:write",
|
||||
}
|
||||
|
||||
meta = context_store.create_project(
|
||||
"daniele",
|
||||
"Progetto unit test",
|
||||
tags=["test"],
|
||||
)
|
||||
meta_path = os.path.join(TEST_ROOT, "daniele", "projects", meta["id"], "meta.json")
|
||||
meta["gitea_repo"] = "daniele/rete"
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
|
||||
ctx = tools.call_tool(
|
||||
"get_project_context",
|
||||
{"project_id": meta["id"], "include_gitea": False},
|
||||
claims,
|
||||
)
|
||||
payload = json.loads(ctx["content"][0]["text"])
|
||||
assert payload["meta"]["gitea_repo"] == "daniele/rete"
|
||||
|
||||
unlinked = tools.call_tool(
|
||||
"link_project_repo",
|
||||
{"project_id": meta["id"], "gitea_repo": ""},
|
||||
claims,
|
||||
)
|
||||
payload = json.loads(unlinked["content"][0]["text"])
|
||||
assert "gitea_repo" not in payload
|
||||
|
||||
print("OK unit: meta gitea_repo + scollegamento locale")
|
||||
|
||||
|
||||
def run_live_tests() -> None:
|
||||
_load_dotenv()
|
||||
token = os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip()
|
||||
if not token:
|
||||
print("SKIP live: GITEA_API_TOKEN_DANIELE assente")
|
||||
return
|
||||
|
||||
live_root = os.path.join(TEST_ROOT, "live")
|
||||
os.makedirs(live_root, exist_ok=True)
|
||||
os.environ["MCP_CONTEXT_ROOT"] = live_root
|
||||
|
||||
from app.context import store as context_store
|
||||
from app.mcp import tools
|
||||
from app.knowledge import gitea
|
||||
|
||||
gitea._tokens_cache = None
|
||||
claims = {
|
||||
"sub": "daniele",
|
||||
"scope": "context:read context:write gitea:read gitea:write",
|
||||
}
|
||||
|
||||
project_id = "rete-ha-p2-test"
|
||||
proj_dir = os.path.join(live_root, "daniele", "projects", project_id)
|
||||
if os.path.isdir(proj_dir):
|
||||
shutil.rmtree(proj_dir)
|
||||
|
||||
created = tools.call_tool(
|
||||
"create_project",
|
||||
{
|
||||
"title": "Rete HA P2 test",
|
||||
"tags": ["infra", "test"],
|
||||
"gitea_repo": "daniele/rete",
|
||||
"seed_from_gitea": True,
|
||||
},
|
||||
claims,
|
||||
)
|
||||
payload = json.loads(created["content"][0]["text"])
|
||||
project_id = payload["id"]
|
||||
assert payload.get("gitea_repo") == "daniele/rete"
|
||||
assert payload.get("gitea_seeded_at")
|
||||
|
||||
ctx_path = os.path.join(live_root, "daniele", "projects", project_id, "context.md")
|
||||
context_md = open(ctx_path, encoding="utf-8").read()
|
||||
assert "seed:gitea daniele/rete" in context_md
|
||||
assert len(context_md) > 200
|
||||
|
||||
enriched = tools.call_tool(
|
||||
"get_project_context",
|
||||
{"project_id": project_id, "include_gitea": True, "session_limit": 0},
|
||||
claims,
|
||||
)
|
||||
data = json.loads(enriched["content"][0]["text"])
|
||||
assert data["meta"]["gitea_repo"] == "daniele/rete"
|
||||
assert data["gitea"]["available"] is True
|
||||
assert data["gitea"]["readme"] is not None
|
||||
assert isinstance(data["gitea"].get("docs_files"), list)
|
||||
|
||||
link_existing = tools.call_tool(
|
||||
"link_project_repo",
|
||||
{"project_id": project_id, "gitea_repo": "daniele/rete", "seed_from_gitea": True},
|
||||
claims,
|
||||
)
|
||||
meta2 = json.loads(link_existing["content"][0]["text"])
|
||||
assert "gitea_seeded_at" in meta2
|
||||
|
||||
shutil.rmtree(proj_dir)
|
||||
print(f"OK live: create+seed+enrichment su daniele/rete (project {project_id})")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
run_unit_tests()
|
||||
run_live_tests()
|
||||
return 0
|
||||
finally:
|
||||
shutil.rmtree(TEST_ROOT, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
os.environ.setdefault('MCP_DB','/data/loogle_mcp.db')
|
||||
os.environ.setdefault('MCP_VECTOR_FALLBACK','/data/vector_fallback.db')
|
||||
from app.db import init_db, get_conn
|
||||
from app.knowledge import gitea_indexer
|
||||
from app.mcp import tools
|
||||
init_db()
|
||||
assert get_conn().execute("SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'").fetchone()
|
||||
repo='daniele/rete'
|
||||
for path in ['ha/FAILOVER-VOLUMES.md','ha/RUNBOOK-failover.md']:
|
||||
r=gitea_indexer.index_file(repo, path, username='daniele', private=True, force=True)
|
||||
print('indexed', path, r.get('chunks'), r.get('skipped'), flush=True)
|
||||
files=gitea_indexer.list_indexed_files(limit=10, repo=repo)
|
||||
print('db_files', [f['path'] for f in files], flush=True)
|
||||
for q in ('failover tier-b','runbook'):
|
||||
hits=gitea_indexer.search_gitea_knowledge('daniele', q, limit=3, is_admin=True)
|
||||
print('search', q, hits[0]['path'] if hits else None, round(float(hits[0]['score']),3) if hits else None, flush=True)
|
||||
if not hits: sys.exit(1)
|
||||
claims={'sub':'daniele','scope':'knowledge:read gitea:read admin'}
|
||||
for tool,args in [
|
||||
('search_gitea_knowledge',{'query':'failover tier-b','limit':5}),
|
||||
('list_gitea_indexed_files',{'repo':repo,'limit':10}),
|
||||
('reindex_gitea_repo',{'repo':repo,'max_files':1}),
|
||||
('search_knowledge',{'query':'failover keepalived','limit':8}),
|
||||
]:
|
||||
out=tools.call_tool(tool,args,claims)
|
||||
p=json.loads(out['content'][0]['text'])
|
||||
if tool=='search_knowledge':
|
||||
src=sorted({r.get('source') for r in p['results']})
|
||||
print(tool, src, flush=True)
|
||||
assert 'gitea' in src
|
||||
elif tool=='list_gitea_indexed_files':
|
||||
print(tool, p['count'], flush=True)
|
||||
else:
|
||||
print(tool, 'ok', flush=True)
|
||||
print('OK P3 COMPLETE', flush=True)
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
os.environ.setdefault('MCP_DB','/data/loogle_mcp.db')
|
||||
os.environ.setdefault('MCP_VECTOR_FALLBACK','/data/vector_fallback.db')
|
||||
from app.db import init_db, get_conn
|
||||
from app.knowledge import gitea_indexer
|
||||
from app.mcp import tools
|
||||
init_db()
|
||||
print('table_ok', bool(get_conn().execute("SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'").fetchone()), flush=True)
|
||||
files=gitea_indexer.list_indexed_files(limit=10, repo='daniele/rete')
|
||||
print('indexed', [f['path'] for f in files], flush=True)
|
||||
if not files:
|
||||
print('WARN: nessun file indicizzato — indicizzo stacks.conf (piccolo)', flush=True)
|
||||
r=gitea_indexer.index_file('daniele/rete','ha/stacks.conf', username='daniele', private=True, force=True)
|
||||
print('new_index', r, flush=True)
|
||||
files=gitea_indexer.list_indexed_files(limit=10, repo='daniele/rete')
|
||||
for q in ('failover','censimento','tier-b'):
|
||||
hits=gitea_indexer.search_gitea_knowledge('daniele', q, limit=3, is_admin=True)
|
||||
print('search', q, hits[0]['path'] if hits else 'NONE', round(float(hits[0]['score']),3) if hits else None, flush=True)
|
||||
if not hits and q=='failover':
|
||||
sys.exit(1)
|
||||
claims={'sub':'daniele','scope':'knowledge:read gitea:read admin'}
|
||||
sg=tools.call_tool('search_gitea_knowledge',{'query':'failover tier-b','limit':5},claims)
|
||||
assert json.loads(sg['content'][0]['text'])['results']
|
||||
print('tool search_gitea_knowledge ok', flush=True)
|
||||
li=tools.call_tool('list_gitea_indexed_files',{'repo':'daniele/rete','limit':10},claims)
|
||||
print('tool list', json.loads(li['content'][0]['text'])['count'], flush=True)
|
||||
ri=tools.call_tool('reindex_gitea_repo',{'repo':'daniele/rete','max_files':1},claims)
|
||||
print('tool reindex', json.loads(ri['content'][0]['text']).get('files_indexed'), flush=True)
|
||||
sk=tools.call_tool('search_knowledge',{'query':'failover keepalived','limit':8},claims)
|
||||
src=sorted({r.get('source') for r in json.loads(sk['content'][0]['text'])['results']})
|
||||
print('tool search_knowledge sources', src, flush=True)
|
||||
assert 'gitea' in src
|
||||
print('OK P3 SEARCH VERIFIED', flush=True)
|
||||
@@ -0,0 +1,86 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Background indexer worker — cicli separati Paperless / Gitea / Apps."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
LOGGER = logging.getLogger("loogle_mcp.worker")
|
||||
|
||||
from app.db import init_db # noqa: E402
|
||||
from app.knowledge import apps_indexer, gitea_indexer, indexer # noqa: E402
|
||||
|
||||
|
||||
def _run_gitea() -> None:
|
||||
if os.environ.get("GITEA_INDEX_ENABLED", "yes").strip().lower() in ("0", "false", "no", "off"):
|
||||
LOGGER.info("Indicizzazione Gitea disabilitata (GITEA_INDEX_ENABLED)")
|
||||
return
|
||||
gitea_result = gitea_indexer.index_all()
|
||||
stats = gitea_indexer.index_stats()
|
||||
LOGGER.info("Indicizzazione Gitea completata: %s stats=%s", gitea_result, stats)
|
||||
|
||||
|
||||
def _run_paperless() -> None:
|
||||
result = indexer.index_all(max_pages=10)
|
||||
LOGGER.info("Indicizzazione Paperless completata: %s", result)
|
||||
|
||||
|
||||
def _run_apps() -> None:
|
||||
apps_result = apps_indexer.index_all()
|
||||
LOGGER.info("Indicizzazione Apps (P7) completata: %s", apps_result)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
init_db()
|
||||
interval = int(os.environ.get("INDEXER_INTERVAL_MINUTES", "30")) * 60
|
||||
LOGGER.info("Indexer avviato, intervallo %ds", interval)
|
||||
|
||||
from app.knowledge import thermal # noqa: WPS433
|
||||
|
||||
status = thermal.read_status()
|
||||
if status:
|
||||
LOGGER.info(
|
||||
"Thermal probe OK: temp=%.1f°C load1=%.2f nproc=%s source=%s (hard=%.0f°C soft=%.0f°C cpu_target=%.0f%%)",
|
||||
float(status.get("cpu_temp_c") or 0),
|
||||
float(status.get("load1") or 0),
|
||||
status.get("nproc"),
|
||||
status.get("source"),
|
||||
thermal.hard_temp_c(),
|
||||
thermal.soft_temp_c(),
|
||||
thermal.cpu_target_pct(),
|
||||
)
|
||||
else:
|
||||
LOGGER.warning("Thermal probe non raggiungibile all'avvio — gate userà delay conservativi")
|
||||
|
||||
# Gitea per primo: repo piccoli, non bloccato da Paperless lento
|
||||
try:
|
||||
thermal.wait_for_headroom(context="cycle:gitea-boot")
|
||||
_run_gitea()
|
||||
except Exception as exc:
|
||||
LOGGER.error("Indicizzazione Gitea fallita: %s", exc)
|
||||
|
||||
while True:
|
||||
try:
|
||||
thermal.wait_for_headroom(context="cycle:gitea")
|
||||
_run_gitea()
|
||||
except Exception as exc:
|
||||
LOGGER.error("Indicizzazione Gitea fallita: %s", exc)
|
||||
|
||||
try:
|
||||
thermal.wait_for_headroom(context="cycle:paperless")
|
||||
_run_paperless()
|
||||
except Exception as exc:
|
||||
LOGGER.error("Indicizzazione Paperless fallita: %s", exc)
|
||||
|
||||
try:
|
||||
thermal.wait_for_headroom(context="cycle:apps")
|
||||
_run_apps()
|
||||
except Exception as exc:
|
||||
LOGGER.error("Indicizzazione Apps fallita: %s", exc)
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -337,7 +337,7 @@ def load_state() -> Dict:
|
||||
return default
|
||||
|
||||
|
||||
def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = None, last_notification_utc: Optional[str] = None) -> None:
|
||||
def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = None, last_notification_utc: Optional[str] = None, summary: Optional[str] = None) -> None:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
||||
state_data = {
|
||||
@@ -354,6 +354,8 @@ def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = N
|
||||
"casa_first_thr_time": casa_data.get("first_thr_time", ""),
|
||||
"casa_duration_hours": casa_data.get("duration_hours", 0.0),
|
||||
})
|
||||
if summary:
|
||||
state_data["summary"] = summary[:3000]
|
||||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(state_data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
@@ -1694,7 +1696,15 @@ def analyze_snow(chat_ids: Optional[List[str]] = None, debug_mode: bool = False)
|
||||
msg.append("<i>Fonte dati: Open-Meteo</i>")
|
||||
|
||||
# Unisci con <br> (sarà convertito in \n in telegram_send_html)
|
||||
ok = telegram_send_html("<br>".join(msg), chat_ids=chat_ids)
|
||||
html_msg = "<br>".join(msg)
|
||||
ok = telegram_send_html(html_msg, chat_ids=chat_ids)
|
||||
snow_summary = None
|
||||
try:
|
||||
from webapp_alert import publish_web_alert, message_to_plain
|
||||
snow_summary = message_to_plain(html_msg, is_html=True)
|
||||
publish_web_alert(html_msg, "snow", "warning", is_html=True, title="Allerta neve")
|
||||
except Exception as e:
|
||||
LOGGER.debug("Web summary failed: %s", e)
|
||||
|
||||
# Genera e invia grafico (solo se abbiamo dati per Casa)
|
||||
chart_generated = False
|
||||
@@ -1731,11 +1741,11 @@ def analyze_snow(chat_ids: Optional[List[str]] = None, debug_mode: bool = False)
|
||||
|
||||
# Salva timestamp dell'ultima notifica
|
||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
||||
save_state(True, sig, casa_data, last_notification_utc=now_utc.isoformat(timespec="seconds"))
|
||||
save_state(True, sig, casa_data, last_notification_utc=now_utc.isoformat(timespec="seconds"), summary=snow_summary)
|
||||
else:
|
||||
LOGGER.warning("Notifica neve NON inviata (token mancante o errore Telegram).")
|
||||
# Salva comunque lo state (senza aggiornare last_notification_utc)
|
||||
save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", ""))
|
||||
save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", ""), summary=snow_summary)
|
||||
else:
|
||||
LOGGER.info("Allerta attiva ma nessun cambiamento significativo. Motivo: %s", change_reason)
|
||||
# Salva lo state anche se non inviamo (per mantenere alert_active e last_notification_utc)
|
||||
|
||||
@@ -758,6 +758,14 @@ async def meteo_viaggio_command(update: Update, context: ContextTypes.DEFAULT_TY
|
||||
|
||||
async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
# Stesso comportamento di `/meteo` senza argomenti: Casa (+ viaggio se attivo) per utente.
|
||||
try:
|
||||
from telegram_gate import telegram_alerts_enabled
|
||||
if not telegram_alerts_enabled():
|
||||
logger.info("Report meteo mattutino sospeso (LOOGLE_TELEGRAM_ALERTS=0).")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
report_casa = call_meteo_script(["--home"])
|
||||
for uid in ALLOWED_IDS:
|
||||
chat_id = str(uid)
|
||||
@@ -892,7 +900,21 @@ def main():
|
||||
application.add_handler(CallbackQueryHandler(button_handler))
|
||||
|
||||
job_queue = application.job_queue
|
||||
job_queue.run_daily(scheduled_morning_report, time=datetime.time(hour=7, minute=15, tzinfo=TZINFO), days=(0, 1, 2, 3, 4, 5, 6))
|
||||
# Report meteo quotidiano 07:15 — sospeso se LOOGLE_TELEGRAM_ALERTS=0 (cron-alerts.env)
|
||||
try:
|
||||
from telegram_gate import telegram_alerts_enabled
|
||||
morning_enabled = telegram_alerts_enabled()
|
||||
except Exception:
|
||||
morning_enabled = True
|
||||
if morning_enabled:
|
||||
job_queue.run_daily(
|
||||
scheduled_morning_report,
|
||||
time=datetime.time(hour=7, minute=15, tzinfo=TZINFO),
|
||||
days=(0, 1, 2, 3, 4, 5, 6),
|
||||
)
|
||||
logger.info("Job scheduled_morning_report attivo (07:15).")
|
||||
else:
|
||||
logger.info("Job scheduled_morning_report NON registrato (Telegram sospeso).")
|
||||
|
||||
application.run_polling()
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ def get_bot_token():
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def save_current_state(state, report_meta=None):
|
||||
def save_current_state(state, report_meta=None, summary=None):
|
||||
try:
|
||||
# Aggiungi timestamp corrente per tracciare quando è stato salvato lo stato
|
||||
if report_meta is None:
|
||||
@@ -87,7 +87,19 @@ def save_current_state(state, report_meta=None):
|
||||
"points": state,
|
||||
"last_update": datetime.datetime.now().isoformat(),
|
||||
"report_meta": report_meta,
|
||||
"alert_active": any(int(v or 0) > 0 for v in (state or {}).values()),
|
||||
}
|
||||
if summary:
|
||||
state_with_meta["summary"] = str(summary)[:3000]
|
||||
else:
|
||||
# Mantieni l'ultimo summary Telegram se non ci sono nuovi aggiornamenti
|
||||
try:
|
||||
with open(STATE_FILE, "r") as rf:
|
||||
prev = json.load(rf)
|
||||
if isinstance(prev, dict) and prev.get("summary"):
|
||||
state_with_meta["summary"] = str(prev["summary"])[:3000]
|
||||
except Exception:
|
||||
pass
|
||||
with open(STATE_FILE, 'w') as f:
|
||||
json.dump(state_with_meta, f)
|
||||
except Exception as e:
|
||||
@@ -2251,7 +2263,26 @@ def main():
|
||||
append_report(new_alerts, improvement_msg, important, report_meta, DEBUG_MODE)
|
||||
|
||||
# Genera e invia mappa solo quando ci sono aggiornamenti
|
||||
ice_summary = None
|
||||
if new_alerts or solved_alerts:
|
||||
parts = []
|
||||
if new_alerts:
|
||||
parts.append("Aggiornamenti rischio:\n" + "\n\n".join(new_alerts[:12]))
|
||||
if solved_alerts:
|
||||
parts.append("Rientri:\n" + "\n".join(solved_alerts[:8]))
|
||||
ice_summary = "\n\n".join(parts)
|
||||
try:
|
||||
from webapp_alert import publish_web_alert
|
||||
publish_web_alert(
|
||||
ice_summary,
|
||||
"ghiaccio",
|
||||
"warning",
|
||||
is_html=True,
|
||||
title="Rischio ghiaccio stradale",
|
||||
)
|
||||
except Exception as e:
|
||||
if DEBUG_MODE:
|
||||
print(f"Web summary failed: {e}")
|
||||
if DEBUG_MODE:
|
||||
print(f"Generazione mappa per {len(map_points_data)} punti...")
|
||||
map_path = os.path.join(SCRIPT_DIR, "ice_risk_map.png")
|
||||
@@ -2265,18 +2296,16 @@ def main():
|
||||
f"🕒 {now.strftime('%d/%m/%Y %H:%M')}\n"
|
||||
f"📊 Punti monitorati: {len(map_points_data)}"
|
||||
)
|
||||
# Invia anche i report testuali (allineati a Telegram/WebApp)
|
||||
for block in (new_alerts + solved_alerts)[:15]:
|
||||
try:
|
||||
send_telegram_broadcast(token, block, debug_mode=DEBUG_MODE)
|
||||
except Exception:
|
||||
pass
|
||||
photo_sent = send_telegram_photo(token, map_path, caption, debug_mode=DEBUG_MODE)
|
||||
if DEBUG_MODE:
|
||||
print(f"Mappa inviata via Telegram: {photo_sent}")
|
||||
# Pulisci file temporaneo solo se non in debug mode (per permettere verifica)
|
||||
if not DEBUG_MODE:
|
||||
try:
|
||||
if os.path.exists(map_path):
|
||||
os.remove(map_path)
|
||||
except Exception:
|
||||
pass
|
||||
elif DEBUG_MODE:
|
||||
print(f"File mappa mantenuto per debug: {map_path}")
|
||||
# Mantieni la mappa per la WebApp (non cancellare)
|
||||
print("Mappa inviata.")
|
||||
else:
|
||||
if DEBUG_MODE:
|
||||
@@ -2288,7 +2317,7 @@ def main():
|
||||
print("Nessuna variazione.")
|
||||
|
||||
if not DEBUG_MODE:
|
||||
save_current_state(current_state, report_meta=report_meta)
|
||||
save_current_state(current_state, report_meta=report_meta, summary=ice_summary)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -52,6 +52,12 @@ TARGET_ZONES = {
|
||||
"EMR-D1": "Pianura bolognese",
|
||||
}
|
||||
|
||||
# Annotazioni territoriali (San Marino adotta il sistema Emilia-Romagna)
|
||||
ZONE_NOTES = {
|
||||
"Alta collina romagnola": "include Repubblica di San Marino",
|
||||
"Pianura romagnola": "area adiacente a San Marino",
|
||||
}
|
||||
|
||||
# Mappa codice zona regionale Arpae -> nome leggibile (deriva da TARGET_ZONES,
|
||||
# togliendo il prefisso "EMR-": es. EMR-D1 -> D1 "Pianura bolognese").
|
||||
REGIONAL_TARGET_ZONES = {code.split("-")[-1]: name for code, name in TARGET_ZONES.items()}
|
||||
@@ -177,15 +183,12 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
||||
message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE)
|
||||
|
||||
try:
|
||||
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
|
||||
from telegram_gate import telegram_alerts_enabled
|
||||
except ImportError:
|
||||
telegram_alerts_enabled = lambda: True # type: ignore
|
||||
mirror_alert_to_web = lambda *a, **k: False # type: ignore
|
||||
|
||||
if not telegram_alerts_enabled():
|
||||
LOGGER.info("Telegram sospeso: skip civil_protection")
|
||||
if message_html:
|
||||
mirror_alert_to_web(message_html, "civil_protection", "warning", is_html=True)
|
||||
return False
|
||||
|
||||
token = load_bot_token()
|
||||
@@ -219,15 +222,6 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
||||
except Exception as e:
|
||||
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
|
||||
|
||||
if sent_ok:
|
||||
try:
|
||||
import sys
|
||||
sys.path.insert(0, "/home/daniely/docker/shared")
|
||||
from loogle_core.alert_dispatcher import mirror_to_web
|
||||
mirror_to_web(message_html, "civil_protection", "warning", is_html=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return sent_ok
|
||||
|
||||
def load_state() -> dict:
|
||||
@@ -441,7 +435,9 @@ def format_message(parsed: dict) -> str:
|
||||
|
||||
lines.append(f"📅 <b>{html_lib.escape(day.get('date_label',''))}</b>")
|
||||
for zone in sorted(alerts.keys()):
|
||||
lines.append(f"📍 <b>{html_lib.escape(zone)}</b>")
|
||||
note = ZONE_NOTES.get(zone)
|
||||
zlabel = f"{zone} · {note}" if note else zone
|
||||
lines.append(f"📍 <b>{html_lib.escape(zlabel)}</b>")
|
||||
for entry in alerts[zone]:
|
||||
lines.append(html_lib.escape(entry))
|
||||
lines.append("")
|
||||
@@ -452,7 +448,9 @@ def format_message(parsed: dict) -> str:
|
||||
lines.append(f"🗺️ <b>{html_lib.escape(titolo)}</b>")
|
||||
alerts = doc.get("alerts", {})
|
||||
for zone in sorted(alerts.keys()):
|
||||
lines.append(f"📍 <b>{html_lib.escape(zone)}</b>")
|
||||
note = ZONE_NOTES.get(zone)
|
||||
zlabel = f"{zone} · {note}" if note else zone
|
||||
lines.append(f"📍 <b>{html_lib.escape(zlabel)}</b>")
|
||||
for entry in alerts[zone]:
|
||||
lines.append(html_lib.escape(entry))
|
||||
lines.append("")
|
||||
@@ -462,6 +460,18 @@ def format_message(parsed: dict) -> str:
|
||||
lines.append("<i>Fonte: mappe.protezionecivile.gov.it</i>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _web_body_without_header(plain: str) -> str:
|
||||
"""Rimuove la riga titolo 'PROTEZIONE CIVILE…' già usata come title WebApp."""
|
||||
lines = (plain or "").splitlines()
|
||||
if not lines:
|
||||
return ""
|
||||
first = lines[0].strip().upper()
|
||||
if "PROTEZIONE CIVILE" in first:
|
||||
return "\n".join(lines[1:]).strip()
|
||||
return plain.strip()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
# =============================================================================
|
||||
@@ -516,21 +526,50 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False):
|
||||
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato")
|
||||
elif sig == last_sig:
|
||||
LOGGER.info("Allerta già notificata e invariata. Nessuna nuova notifica.")
|
||||
# Aggiorna solo la data di calendario così Casa non tratta il bollettino
|
||||
# come «di ieri» mentre le allerte regionali valgono ancora per oggi.
|
||||
today = today_str_italy()
|
||||
if state.get("date") != today:
|
||||
state = dict(state)
|
||||
state["date"] = today
|
||||
save_state(state)
|
||||
LOGGER.info("Stato DPC: date aggiornata a %s (firma invariata).", today)
|
||||
return
|
||||
|
||||
# A questo punto: ci sono allerte e sono nuove -> prova invio
|
||||
msg = format_message(parsed)
|
||||
sent_ok = telegram_send_html(msg, chat_ids=chat_ids)
|
||||
|
||||
if sent_ok:
|
||||
LOGGER.info("Notifica allerta inviata con successo.")
|
||||
save_state({
|
||||
web_ok = False
|
||||
st = {
|
||||
"date": today_str_italy(),
|
||||
"last_alert_signature": sig,
|
||||
})
|
||||
}
|
||||
try:
|
||||
from webapp_alert import publish_web_alert, remember_summary, message_to_plain
|
||||
plain = message_to_plain(msg, is_html=True)
|
||||
card_body = _web_body_without_header(plain)
|
||||
remember_summary(st, card_body)
|
||||
web_ok = bool(
|
||||
publish_web_alert(
|
||||
card_body,
|
||||
"civil_protection",
|
||||
"warning",
|
||||
is_html=False,
|
||||
title="Allerta Protezione Civile / Arpae",
|
||||
state=st,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
LOGGER.debug("Web summary failed: %s", e)
|
||||
|
||||
if sent_ok or web_ok:
|
||||
LOGGER.info(
|
||||
"Notifica allerta consegnata (%s).",
|
||||
"Telegram+WebApp" if sent_ok and web_ok else ("Telegram" if sent_ok else "WebApp"),
|
||||
)
|
||||
save_state(st)
|
||||
else:
|
||||
# Non aggiorniamo lo stato: quando risolvi token/rete, reinvierà.
|
||||
LOGGER.warning("Invio non riuscito (token mancante o errore Telegram). Stato NON aggiornato.")
|
||||
LOGGER.warning("Invio non riuscito (Telegram/WebApp). Stato NON aggiornato.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Civil protection alert")
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
# 0 = sospende Telegram per script meteo/speedtest (WebApp invariata dove configurata)
|
||||
# 0 = sospende Telegram per script meteo/speedtest (canale primario: WebApp Casa)
|
||||
# Le variabili LOOGLE_INTERNAL_TOKEN / LOOGLE_CASA_URL / LOOGLE_NOTIFY_MODE
|
||||
# vengono caricate automaticamente da /home/daniely/docker/loogle-casa/.env
|
||||
LOOGLE_TELEGRAM_ALERTS=0
|
||||
@@ -507,6 +507,13 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
|
||||
|
||||
msg = "".join(msg_parts)
|
||||
ok = telegram_send_html(msg, chat_ids=chat_ids)
|
||||
web_ok = False
|
||||
try:
|
||||
from webapp_alert import publish_web_alert
|
||||
web_ok = bool(publish_web_alert(msg, "freeze", "warning", is_html=True, state=state,
|
||||
title="Allerta gelo"))
|
||||
except Exception as e:
|
||||
LOGGER.debug("Web summary failed: %s", e)
|
||||
if ok:
|
||||
LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s, nuove fasce: %d",
|
||||
min_temp_val, min_temp_time.isoformat(), len(new_periods))
|
||||
@@ -516,8 +523,15 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
|
||||
"start": start.isoformat(),
|
||||
"end": end.isoformat(),
|
||||
})
|
||||
elif web_ok:
|
||||
LOGGER.info("Allerta gelo pubblicata su WebApp. Tmin=%.1f°C", min_temp_val)
|
||||
for start, end in new_periods:
|
||||
notified_periods.append({
|
||||
"start": start.isoformat(),
|
||||
"end": end.isoformat(),
|
||||
})
|
||||
else:
|
||||
LOGGER.warning("Allerta gelo NON inviata (token mancante o errore Telegram).")
|
||||
LOGGER.warning("Allerta gelo NON consegnata (Telegram/WebApp).")
|
||||
|
||||
else:
|
||||
LOGGER.info("Gelo già notificato (nessuna nuova fascia oraria, peggioramento < 2°C). Tmin=%.1f°C", min_temp_val)
|
||||
@@ -528,6 +542,12 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
|
||||
"min_time": min_temp_time.isoformat(),
|
||||
"notified_periods": notified_periods,
|
||||
})
|
||||
if not state.get("summary"):
|
||||
state["summary"] = (
|
||||
f"Allerta gelo a {LOCATION_NAME}.\n"
|
||||
f"Minima prevista {min_temp_val:.1f}°C alle {fmt_dt(min_temp_time)} "
|
||||
f"(prossime {HOURS_AHEAD}h)."
|
||||
)
|
||||
save_state(state)
|
||||
return
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ EXCLUDED_FILES = {
|
||||
"irrigation_cron.log",
|
||||
"road_weather.log",
|
||||
"snow_radar.log",
|
||||
"nowcast_120m_alert.log", # sostituito da meteo-alert (ADR-019, disabilitato 2026-07-25)
|
||||
}
|
||||
SEASONAL_EXCLUDED_FILES = {
|
||||
"freeze_alert.log",
|
||||
@@ -44,9 +45,45 @@ CATEGORIES = {
|
||||
"telegram_error": re.compile(r"Telegram error|Bad Request|chat not found|can't parse entities", re.IGNORECASE),
|
||||
"traceback": re.compile(r"Traceback", re.IGNORECASE),
|
||||
"exception": re.compile(r"\bERROR\b|Exception", re.IGNORECASE),
|
||||
"token_missing": re.compile(r"token missing|Token Telegram assente", re.IGNORECASE),
|
||||
# Solo token realmente assente (non i messaggi ambigui con Telegram sospeso)
|
||||
"token_missing": re.compile(
|
||||
r"Telegram token missing|Token Telegram assente|Token Telegram mancante",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
}
|
||||
|
||||
# Comportamento atteso con LOOGLE_TELEGRAM_ALERTS=0 / canale WebApp: non sono problemi.
|
||||
IGNORE_ISSUE_PATTERNS = [
|
||||
re.compile(r"Telegram sospeso", re.IGNORECASE),
|
||||
re.compile(r"Alert NON inviato \(token missing o errore Telegram\)", re.IGNORECASE),
|
||||
re.compile(r"NOT sent \(token missing or Telegram error\)", re.IGNORECASE),
|
||||
re.compile(r"Notifica NON inviata \(token/telegram\)", re.IGNORECASE),
|
||||
re.compile(r"token missing o errore Telegram", re.IGNORECASE),
|
||||
re.compile(r"token mancante o errore Telegram", re.IGNORECASE),
|
||||
re.compile(r"Telegram saltato/sospeso", re.IGNORECASE),
|
||||
re.compile(r"All-clear Telegram skip/fail", re.IGNORECASE),
|
||||
re.compile(r"publish_web_alert failed", re.IGNORECASE), # gestito sotto come web_notify se serve
|
||||
]
|
||||
|
||||
|
||||
def telegram_alerts_suspended() -> bool:
|
||||
env_path = os.path.join(BASE_DIR, "cron-alerts.env")
|
||||
try:
|
||||
with open(env_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith("LOOGLE_TELEGRAM_ALERTS="):
|
||||
val = line.split("=", 1)[1].strip().strip('"').strip("'").lower()
|
||||
return val in ("0", "off", "false", "no", "disabled")
|
||||
except OSError:
|
||||
pass
|
||||
val = os.environ.get("LOOGLE_TELEGRAM_ALERTS", "1").strip().lower()
|
||||
return val in ("0", "off", "false", "no", "disabled")
|
||||
|
||||
|
||||
def should_ignore_issue_line(line: str) -> bool:
|
||||
return any(p.search(line) for p in IGNORE_ISSUE_PATTERNS)
|
||||
|
||||
|
||||
def load_text_file(path: str) -> str:
|
||||
try:
|
||||
@@ -126,6 +163,8 @@ def analyze_logs(files: List[str], since: datetime.datetime, max_lines: int) ->
|
||||
last_ts = ts
|
||||
if not last_ts or last_ts < since:
|
||||
continue
|
||||
if should_ignore_issue_line(line):
|
||||
continue
|
||||
for cat, regex in CATEGORIES.items():
|
||||
if regex.search(line):
|
||||
category_hits[cat].append((last_ts, path, line))
|
||||
@@ -169,6 +208,8 @@ def format_report(
|
||||
lines.append(f"🧾 Log Monitor - ultimi {days} giorni")
|
||||
lines.append(f"Intervallo: {since.strftime('%Y-%m-%d %H:%M')} → {now.strftime('%Y-%m-%d %H:%M')}")
|
||||
lines.append(f"File analizzati: {len(files)}")
|
||||
if telegram_alerts_suspended():
|
||||
lines.append("ℹ️ Telegram sospeso (LOOGLE_TELEGRAM_ALERTS=0): canale primario WebApp Casa")
|
||||
lines.append("")
|
||||
|
||||
# Sezione log non aggiornati
|
||||
|
||||
@@ -17,6 +17,7 @@ from open_meteo_precip import (
|
||||
CASA_TZ,
|
||||
daily_precip_from_hourly,
|
||||
hourly_precip_mm,
|
||||
hourly_table_should_show,
|
||||
is_casa,
|
||||
)
|
||||
|
||||
@@ -499,16 +500,15 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
|
||||
day_date = dt.date()
|
||||
is_new_day = (current_day is not None and day_date != current_day)
|
||||
|
||||
# Determina se mostrare questo timestamp in base alla posizione nelle 48h
|
||||
# Prime 24h: ogni ora (step=1)
|
||||
# Dalla 25a alla 48a: ogni 2 ore (step=2)
|
||||
if hours_from_start < 24:
|
||||
step = 1 # Prime 24h: dettaglio 1 ora
|
||||
else:
|
||||
step = 2 # Dalla 25a alla 48a: dettaglio 2 ore
|
||||
|
||||
# Controlla se questo timestamp deve essere mostrato
|
||||
should_show = (hours_from_start % step == 0)
|
||||
# Prime 24h: ogni ora. Dalla 25a alla 48a: ogni 2 ore, ma mai nascondere
|
||||
# un'ora con precipitazione (altrimenti il picco cade sulle ore dispari
|
||||
# e la tabella 48h mostra 5 mm mentre Meteo7 ha 22.4 mm sul giorno).
|
||||
Pr_early = get_val(l_prec[idx], 0)
|
||||
Rain_early = get_val(l_rain[idx], 0)
|
||||
Showers_early = get_val(l_showers[idx], 0) if idx < len(l_showers) else 0
|
||||
Code_early = int(get_val(l_code[idx], 0))
|
||||
Pr_display = hourly_precip_mm(Pr_early, Rain_early, Showers_early)
|
||||
should_show = hourly_table_should_show(hours_from_start, Pr_display, Code_early)
|
||||
|
||||
# Se è un nuovo giorno, chiudi il blocco precedente
|
||||
if is_new_day and current_block_lines:
|
||||
@@ -541,12 +541,11 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
|
||||
elif diff >= 2.5: t_suffix = "H"
|
||||
t_s = f"{int(round(T))}{t_suffix}"
|
||||
|
||||
Pr = get_val(l_prec[idx], 0)
|
||||
Pr = Pr_early
|
||||
Sn = get_val(l_snow[idx], 0)
|
||||
Code = int(get_val(l_code[idx], 0))
|
||||
Rain = get_val(l_rain[idx], 0)
|
||||
Showers = get_val(l_showers[idx], 0) if idx < len(l_showers) else 0
|
||||
Pr_display = hourly_precip_mm(Pr, Rain, Showers)
|
||||
Code = Code_early
|
||||
Rain = Rain_early
|
||||
Showers = Showers_early
|
||||
|
||||
# Determina se è neve
|
||||
is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86])
|
||||
@@ -676,7 +675,7 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
|
||||
|
||||
legend = {
|
||||
"temp": "W=wind chill, H=heat index",
|
||||
"precip": "G=grandine, Z=ghiacciato, N=neve",
|
||||
"precip": "G=grandine, Z=ghiacciato, N=neve. Dalla 25ª ora: secco ogni 2h, pioggia sempre visibile",
|
||||
"cloud": "FOG=nebbia",
|
||||
"sky": "Icona condizioni (☀️🌧️⛈️…)",
|
||||
"sx": "☃️ neve · 🧊 ghiaccio · ⚡/🌪️ temporali · 🥵 caldo · ☔️ pioggia · 💨 vento forte",
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""DEPRECATED 2026-07-25 (ADR-019).
|
||||
|
||||
Sostituito da ``meteo-alert imminent`` (layer NWP 0–120′ in meteo-alert).
|
||||
Il cron è disabilitato. Per forzare questo script legacy:
|
||||
FORCE_LEGACY_NOWCAST_120M=1 python3 nowcast_120m_alert.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
@@ -15,6 +22,14 @@ import requests
|
||||
from dateutil import parser
|
||||
from open_meteo_client import open_meteo_get
|
||||
|
||||
if os.environ.get("FORCE_LEGACY_NOWCAST_120M", "").strip() != "1":
|
||||
print(
|
||||
"DEPRECATED: use `meteo-alert imminent` (ADR-019). "
|
||||
"Set FORCE_LEGACY_NOWCAST_120M=1 to run this script.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(0)
|
||||
|
||||
# =========================
|
||||
# CONFIG
|
||||
# =========================
|
||||
@@ -129,14 +144,12 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
|
||||
return False
|
||||
|
||||
try:
|
||||
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
|
||||
from telegram_gate import telegram_alerts_enabled
|
||||
except ImportError:
|
||||
telegram_alerts_enabled = lambda: True # type: ignore
|
||||
mirror_alert_to_web = lambda *a, **k: False # type: ignore
|
||||
|
||||
if not telegram_alerts_enabled():
|
||||
LOGGER.info("Telegram sospeso: skip nowcast_120m")
|
||||
mirror_alert_to_web(message, "nowcast_120m", "warning", is_html=False)
|
||||
return False
|
||||
|
||||
token = load_bot_token()
|
||||
@@ -169,15 +182,6 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
|
||||
except Exception as e:
|
||||
LOGGER.exception("Errore invio Telegram chat_id=%s: %s", chat_id, e)
|
||||
|
||||
if ok_any:
|
||||
try:
|
||||
import sys
|
||||
sys.path.insert(0, "/home/daniely/docker/shared")
|
||||
from loogle_core.alert_dispatcher import mirror_to_web
|
||||
mirror_to_web(message, "nowcast_120m", "warning", is_html=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ok_any
|
||||
|
||||
|
||||
@@ -991,16 +995,25 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
|
||||
)
|
||||
|
||||
ok = telegram_send_markdown(msg, chat_ids=chat_ids)
|
||||
if ok:
|
||||
LOGGER.info("Notifica inviata.")
|
||||
web_ok = False
|
||||
try:
|
||||
from webapp_alert import publish_web_alert
|
||||
web_ok = bool(publish_web_alert(msg, "nowcast_120m", "warning", is_html=False, state=state))
|
||||
except Exception as e:
|
||||
LOGGER.debug("Web summary failed: %s", e)
|
||||
|
||||
# Salva state con eventi attivi aggiornati
|
||||
state["active_events"] = active_events
|
||||
if ok or web_ok:
|
||||
state["last_sent_utc"] = now_utc.isoformat(timespec="seconds")
|
||||
save_state(state)
|
||||
LOGGER.info("Notifica consegnata (%s).", "Telegram" if ok else "WebApp")
|
||||
else:
|
||||
LOGGER.error("Notifica NON inviata (token/telegram).")
|
||||
# Salva comunque lo state aggiornato
|
||||
state["active_events"] = active_events
|
||||
LOGGER.warning("Notifica NON consegnata (Telegram/WebApp).")
|
||||
try:
|
||||
from webapp_alert import remember_summary, message_to_plain
|
||||
remember_summary(state, message_to_plain(msg, is_html=False))
|
||||
except Exception:
|
||||
pass
|
||||
save_state(state)
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ ICON_DAILY_VARS = (
|
||||
|
||||
PRECIP_HOURLY_KEYS = ("precipitation", "rain", "showers", "snowfall")
|
||||
PRECIP_DAILY_KEYS = ("precipitation_sum", "rain_sum", "showers_sum", "snowfall_sum", "precipitation_hours")
|
||||
WMO_PRECIP_CODES = frozenset(
|
||||
list(range(51, 68)) + list(range(71, 78)) + list(range(80, 83)) + [85, 86, 95, 96, 99]
|
||||
)
|
||||
|
||||
|
||||
def is_casa(lat: float, lon: float, tol: float = 0.01) -> bool:
|
||||
@@ -84,6 +87,61 @@ def daily_precip_from_hourly(hourly: Dict) -> Dict[str, float]:
|
||||
return dict(out)
|
||||
|
||||
|
||||
def daily_component_from_hourly(hourly: Dict, key: str) -> Dict[str, float]:
|
||||
"""Somma un campo orario (rain, showers, snowfall) per data locale YYYY-MM-DD."""
|
||||
times = hourly.get("time") or []
|
||||
arr = hourly.get(key) or []
|
||||
out: Dict[str, float] = defaultdict(float)
|
||||
for i, t in enumerate(times):
|
||||
if not t or i >= len(arr) or arr[i] is None:
|
||||
continue
|
||||
try:
|
||||
out[str(t)[:10]] += float(arr[i])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return dict(out)
|
||||
|
||||
|
||||
def apply_hourly_daily_precip(daily: Dict, hourly: Dict) -> Dict:
|
||||
"""Allinea i totali daily alla somma delle ore (stessa metrica della tabella oraria)."""
|
||||
daily = dict(daily)
|
||||
times = daily.get("time") or []
|
||||
mapping = {
|
||||
"precipitation_sum": daily_precip_from_hourly(hourly),
|
||||
"rain_sum": daily_component_from_hourly(hourly, "rain"),
|
||||
"showers_sum": daily_component_from_hourly(hourly, "showers"),
|
||||
"snowfall_sum": daily_component_from_hourly(hourly, "snowfall"),
|
||||
}
|
||||
for key, totals in mapping.items():
|
||||
arr = list(daily.get(key) or [])
|
||||
while len(arr) < len(times):
|
||||
arr.append(None)
|
||||
for i, t in enumerate(times):
|
||||
d = str(t)[:10]
|
||||
if d in totals:
|
||||
arr[i] = round(totals[d], 2)
|
||||
daily[key] = arr
|
||||
return daily
|
||||
|
||||
|
||||
def hourly_table_should_show(hours_from_start: int, precip_mm: float, weathercode: int = 0) -> bool:
|
||||
"""
|
||||
Tabella 48h: prime 24 ore tutte; dalle 25 alle 48 ogni 2 ore,
|
||||
ma un'ora con precipitazione (mm o codice WMO) non viene mai omessa.
|
||||
"""
|
||||
if hours_from_start < 24:
|
||||
return True
|
||||
if hours_from_start % 2 == 0:
|
||||
return True
|
||||
if float(precip_mm or 0) >= 0.1:
|
||||
return True
|
||||
try:
|
||||
code = int(weathercode or 0)
|
||||
except (TypeError, ValueError):
|
||||
code = 0
|
||||
return code in WMO_PRECIP_CODES
|
||||
|
||||
|
||||
def daily_precip_sum(daily: Dict, date_str: str) -> Optional[float]:
|
||||
times = daily.get("time") or []
|
||||
arr = daily.get("precipitation_sum") or []
|
||||
@@ -125,8 +183,19 @@ def fetch_icon_italia(
|
||||
return None
|
||||
|
||||
|
||||
def _time_key(t) -> str:
|
||||
if not t:
|
||||
return ""
|
||||
return str(t).strip()[:16]
|
||||
|
||||
|
||||
def _index_by_time(section: Dict) -> Dict[str, int]:
|
||||
return {str(t): i for i, t in enumerate(section.get("time") or [])}
|
||||
out: Dict[str, int] = {}
|
||||
for i, t in enumerate(section.get("time") or []):
|
||||
k = _time_key(t)
|
||||
if k:
|
||||
out[k] = i
|
||||
return out
|
||||
|
||||
|
||||
def overlay_icon_precip_on_hourly(target: Dict, icon_hourly: Dict) -> Dict:
|
||||
|
||||
@@ -18,7 +18,7 @@ from open_meteo_precip import (
|
||||
CASA_LAT,
|
||||
CASA_LON,
|
||||
CASA_TZ,
|
||||
daily_precip_from_hourly,
|
||||
apply_hourly_daily_precip,
|
||||
fetch_icon_italia,
|
||||
hourly_precip_at_index,
|
||||
hourly_precip_series,
|
||||
@@ -342,6 +342,38 @@ def _median_or_single(values):
|
||||
return median(nums)
|
||||
|
||||
|
||||
def _merge_weathercode(values, preferred=None):
|
||||
"""
|
||||
Unisce codici WMO categorici con moda (mai mediana: 61+71 → 66 falso gelicidio).
|
||||
In pareggio preferisce `preferred` se presente tra i valori, altrimenti il primo.
|
||||
"""
|
||||
ints = []
|
||||
for v in values:
|
||||
if v is None:
|
||||
continue
|
||||
try:
|
||||
ints.append(int(v))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not ints:
|
||||
return None
|
||||
if preferred is not None:
|
||||
try:
|
||||
preferred = int(preferred)
|
||||
except (TypeError, ValueError):
|
||||
preferred = None
|
||||
counts = {}
|
||||
for c in ints:
|
||||
counts[c] = counts.get(c, 0) + 1
|
||||
max_count = max(counts.values())
|
||||
modes = [c for c, n in counts.items() if n == max_count]
|
||||
if len(modes) == 1:
|
||||
return modes[0]
|
||||
if preferred is not None and preferred in modes:
|
||||
return preferred
|
||||
return ints[0]
|
||||
|
||||
|
||||
# Chiavi solo ICON Italia (precip 0–2d: niente mediana con AROME HD a San Marino)
|
||||
HOURLY_KEYS_ICON_ONLY = [
|
||||
"snow_depth", "showers", "precipitation", "rain", "snowfall",
|
||||
@@ -349,6 +381,7 @@ HOURLY_KEYS_ICON_ONLY = [
|
||||
DAILY_KEYS_ICON_ONLY = [
|
||||
"showers_sum", "precipitation_sum", "rain_sum", "snowfall_sum", "precipitation_hours",
|
||||
]
|
||||
PREFERRED_WEATHERCODE_MODEL = "italia_meteo_arpae_icon_2i"
|
||||
|
||||
|
||||
def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source_model=None):
|
||||
@@ -397,16 +430,25 @@ def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source
|
||||
out[key].append(val)
|
||||
else:
|
||||
vals = []
|
||||
preferred_wc = None
|
||||
for _m, h in hourly_by_model:
|
||||
times = h.get("time", []) or []
|
||||
arr = h.get(key, []) or []
|
||||
for i, t in enumerate(times):
|
||||
if _normalize_time_key(str(t)) == ref_k and i < len(arr) and arr[i] is not None:
|
||||
try:
|
||||
if key == "weathercode":
|
||||
vals.append(int(arr[i]))
|
||||
if _m == PREFERRED_WEATHERCODE_MODEL:
|
||||
preferred_wc = int(arr[i])
|
||||
else:
|
||||
vals.append(float(arr[i]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
break
|
||||
if key == "weathercode":
|
||||
out[key].append(_merge_weathercode(vals, preferred=preferred_wc) if vals else None)
|
||||
else:
|
||||
out[key].append(_median_or_single(vals) if vals else None)
|
||||
n = len(out["time"])
|
||||
if n > 1:
|
||||
@@ -461,16 +503,25 @@ def _merge_daily_median(daily_by_model, single_source_keys=None, single_source_m
|
||||
out[key].append(val)
|
||||
else:
|
||||
vals = []
|
||||
preferred_wc = None
|
||||
for _m, d in daily_by_model:
|
||||
times = d.get("time", []) or []
|
||||
arr = d.get(key, []) or []
|
||||
for i, t in enumerate(times):
|
||||
if str(t)[:10] == date_str and i < len(arr) and arr[i] is not None:
|
||||
try:
|
||||
if key == "weathercode":
|
||||
vals.append(int(arr[i]))
|
||||
if _m == PREFERRED_WEATHERCODE_MODEL:
|
||||
preferred_wc = int(arr[i])
|
||||
else:
|
||||
vals.append(float(arr[i]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
break
|
||||
if key == "weathercode":
|
||||
out[key].append(_merge_weathercode(vals, preferred=preferred_wc) if vals else None)
|
||||
else:
|
||||
out[key].append(_median_or_single(vals) if vals else None)
|
||||
# Ordina cronologicamente (evita buchi nel report se l'unione non era ordinata)
|
||||
n = len(out["time"])
|
||||
@@ -509,6 +560,7 @@ def merge_multi_model_forecast(models_data, forecast_days=10):
|
||||
"snowfall": [],
|
||||
"snow_depth": [],
|
||||
"rain": [],
|
||||
"showers": [],
|
||||
"weathercode": [],
|
||||
"windspeed_10m": [],
|
||||
"winddirection_10m": [],
|
||||
@@ -675,7 +727,7 @@ def format_day_label(day_index: int, daily_time_list, with_relative: bool = True
|
||||
return f"giorno {day_index + 1}"
|
||||
|
||||
def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10):
|
||||
"""Analizza trend temperatura per identificare fronti caldi/freddi con dettaglio completo"""
|
||||
"""Analizza trend di Tmax e Tmin (non la media giornaliera) per identificare cali/rialzi."""
|
||||
if not daily_temps_max or not daily_temps_min:
|
||||
return None
|
||||
|
||||
@@ -683,93 +735,105 @@ def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10):
|
||||
if max_days < 3:
|
||||
return None
|
||||
|
||||
# Filtra valori None e calcola temperature medie giornaliere
|
||||
avg_temps = []
|
||||
valid_indices = []
|
||||
tmax_series = []
|
||||
tmin_series = []
|
||||
for i in range(max_days):
|
||||
t_max = daily_temps_max[i]
|
||||
t_min = daily_temps_min[i]
|
||||
if t_max is not None and t_min is not None:
|
||||
avg_temps.append((float(t_max) + float(t_min)) / 2)
|
||||
valid_indices.append(i)
|
||||
tmax_series.append(float(t_max))
|
||||
tmin_series.append(float(t_min))
|
||||
else:
|
||||
avg_temps.append(None)
|
||||
tmax_series.append(None)
|
||||
tmin_series.append(None)
|
||||
|
||||
if len([t for t in avg_temps if t is not None]) < 3:
|
||||
valid_max = [t for t in tmax_series if t is not None]
|
||||
valid_min = [t for t in tmin_series if t is not None]
|
||||
if len(valid_max) < 3 or len(valid_min) < 3:
|
||||
return None
|
||||
|
||||
# Analizza tendenza generale (prime 3 giorni vs ultimi 3 giorni validi)
|
||||
valid_temps = [t for t in avg_temps if t is not None]
|
||||
if len(valid_temps) < 3:
|
||||
return None
|
||||
first_max = mean(valid_max[:3])
|
||||
last_max = mean(valid_max[-3:])
|
||||
first_min = mean(valid_min[:3])
|
||||
last_min = mean(valid_min[-3:])
|
||||
delta_max = last_max - first_max
|
||||
delta_min = last_min - first_min
|
||||
# Delta dominante per classificare il tipo (maggiore |Δ|)
|
||||
if abs(delta_max) >= abs(delta_min):
|
||||
primary_delta = delta_max
|
||||
primary_series = "max"
|
||||
else:
|
||||
primary_delta = delta_min
|
||||
primary_series = "min"
|
||||
|
||||
first_avg = mean(valid_temps[:3])
|
||||
last_avg = mean(valid_temps[-3:])
|
||||
diff = last_avg - first_avg
|
||||
# Contesto: massime finali ancora elevate → niente retorica "fronte freddo" invernale
|
||||
warm_context = last_max >= 25.0
|
||||
|
||||
trend_type = None
|
||||
trend_intensity = "moderato"
|
||||
|
||||
if diff > 5:
|
||||
trend_type = "fronte_caldo"
|
||||
trend_intensity = "forte" if diff > 8 else "moderato"
|
||||
elif diff > 2:
|
||||
if primary_delta > 5:
|
||||
trend_type = "fronte_caldo" if not warm_context or primary_delta > 8 else "riscaldamento"
|
||||
trend_intensity = "forte" if primary_delta > 8 else "moderato"
|
||||
elif primary_delta > 2:
|
||||
trend_type = "riscaldamento"
|
||||
trend_intensity = "moderato"
|
||||
elif diff < -5:
|
||||
elif primary_delta < -5:
|
||||
if warm_context:
|
||||
trend_type = "calo_termico"
|
||||
else:
|
||||
trend_type = "fronte_freddo"
|
||||
trend_intensity = "forte" if diff < -8 else "moderato"
|
||||
elif diff < -2:
|
||||
trend_intensity = "forte" if primary_delta < -8 else "moderato"
|
||||
elif primary_delta < -2:
|
||||
trend_type = "raffreddamento"
|
||||
trend_intensity = "moderato"
|
||||
else:
|
||||
trend_type = "stabile"
|
||||
|
||||
# Identifica giorni di cambio significativo
|
||||
change_days = []
|
||||
prev_temp = None
|
||||
for i, temp in enumerate(avg_temps):
|
||||
if temp is not None:
|
||||
if prev_temp is not None:
|
||||
day_diff = temp - prev_temp
|
||||
if abs(day_diff) > 3: # Cambio significativo (>3°C)
|
||||
prev_max = prev_min = None
|
||||
for i in range(max_days):
|
||||
tm = tmax_series[i]
|
||||
tn = tmin_series[i]
|
||||
if tm is not None and tn is not None:
|
||||
if prev_max is not None and prev_min is not None:
|
||||
d_max = tm - prev_max
|
||||
d_min = tn - prev_min
|
||||
if abs(d_max) > 3:
|
||||
change_days.append({
|
||||
"day": i,
|
||||
"delta": round(day_diff, 1),
|
||||
"from": round(prev_temp, 1),
|
||||
"to": round(temp, 1)
|
||||
"series": "max",
|
||||
"delta": round(d_max, 1),
|
||||
"from": round(prev_max, 1),
|
||||
"to": round(tm, 1),
|
||||
})
|
||||
prev_temp = temp
|
||||
|
||||
# Analisi per periodi (primi 3 giorni, medio termine, lungo termine)
|
||||
period_analysis = {}
|
||||
if len(valid_temps) >= 7:
|
||||
period_analysis["short_term"] = {
|
||||
"avg": round(mean(valid_temps[:3]), 1),
|
||||
"range": round(max(valid_temps[:3]) - min(valid_temps[:3]), 1)
|
||||
}
|
||||
mid_start = len(valid_temps) // 3
|
||||
mid_end = (len(valid_temps) * 2) // 3
|
||||
period_analysis["mid_term"] = {
|
||||
"avg": round(mean(valid_temps[mid_start:mid_end]), 1),
|
||||
"range": round(max(valid_temps[mid_start:mid_end]) - min(valid_temps[mid_start:mid_end]), 1)
|
||||
}
|
||||
period_analysis["long_term"] = {
|
||||
"avg": round(mean(valid_temps[-3:]), 1),
|
||||
"range": round(max(valid_temps[-3:]) - min(valid_temps[-3:]), 1)
|
||||
}
|
||||
if abs(d_min) > 3:
|
||||
change_days.append({
|
||||
"day": i,
|
||||
"series": "min",
|
||||
"delta": round(d_min, 1),
|
||||
"from": round(prev_min, 1),
|
||||
"to": round(tn, 1),
|
||||
})
|
||||
prev_max, prev_min = tm, tn
|
||||
|
||||
return {
|
||||
"type": trend_type,
|
||||
"intensity": trend_intensity,
|
||||
"delta": round(diff, 1),
|
||||
"delta": round(primary_delta, 1),
|
||||
"delta_max": round(delta_max, 1),
|
||||
"delta_min": round(delta_min, 1),
|
||||
"primary_series": primary_series,
|
||||
"warm_context": warm_context,
|
||||
"change_days": change_days,
|
||||
"first_avg": round(first_avg, 1),
|
||||
"last_avg": round(last_avg, 1),
|
||||
"period_analysis": period_analysis,
|
||||
"daily_avg_temps": avg_temps,
|
||||
"first_max": round(first_max, 1),
|
||||
"last_max": round(last_max, 1),
|
||||
"first_min": round(first_min, 1),
|
||||
"last_min": round(last_min, 1),
|
||||
# Compatibilità chiavi legacy (usate da generate_practical_advice)
|
||||
"first_avg": round((first_max + first_min) / 2, 1),
|
||||
"last_avg": round((last_max + last_min) / 2, 1),
|
||||
"daily_max": daily_temps_max[:max_days],
|
||||
"daily_min": daily_temps_min[:max_days]
|
||||
"daily_min": daily_temps_min[:max_days],
|
||||
}
|
||||
|
||||
def analyze_weather_transitions(daily_weathercodes):
|
||||
@@ -807,13 +871,18 @@ def analyze_weather_transitions(daily_weathercodes):
|
||||
|
||||
return transitions
|
||||
|
||||
def get_precip_type(code):
|
||||
"""Definisce il tipo di precipitazione in base al codice WMO."""
|
||||
if (71 <= code <= 77) or code in [85, 86]:
|
||||
def get_precip_type(code, temp=None):
|
||||
"""Definisce il tipo di precipitazione in base al codice WMO (gate termico per neve/gelicidio)."""
|
||||
try:
|
||||
code = int(code) if code is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
code = 0
|
||||
cold_enough = temp is None or float(temp) <= 1.0
|
||||
if ((71 <= code <= 77) or code in [85, 86]) and cold_enough:
|
||||
return "❄️ Neve"
|
||||
if code in [96, 99]:
|
||||
return "⚡🌨 Grandine"
|
||||
if code in [66, 67]:
|
||||
if code in [66, 67] and cold_enough:
|
||||
return "🧊☔ Pioggia Congelantesi"
|
||||
return "☔ Pioggia"
|
||||
|
||||
@@ -824,6 +893,40 @@ def get_intensity_label(mm_h):
|
||||
return "Moderata"
|
||||
return "Forte ⚠️"
|
||||
|
||||
|
||||
def _add_one_hour_hhmm(hhmm):
|
||||
"""Somma 1 ora a una stringa HH:MM (mod 24)."""
|
||||
try:
|
||||
h, m = hhmm.split(":")
|
||||
return f"{(int(h) + 1) % 24:02d}:{int(m):02d}"
|
||||
except (ValueError, AttributeError):
|
||||
return hhmm
|
||||
|
||||
|
||||
def _format_event_hours(times, start_idx, end_idx_inclusive):
|
||||
"""
|
||||
Formato HH:MM-HH:MM per bucket orari Open-Meteo.
|
||||
Una sola ora → 20:00-21:00 (mai 20:00-20:00). Fine = ora successiva all'ultima inclusa.
|
||||
"""
|
||||
if not times or start_idx < 0 or start_idx >= len(times):
|
||||
return "??:??-??:??"
|
||||
end_idx_inclusive = max(start_idx, min(end_idx_inclusive, len(times) - 1))
|
||||
start_time = times[start_idx].split("T")[1][:5] if "T" in str(times[start_idx]) else str(times[start_idx])[:5]
|
||||
next_idx = end_idx_inclusive + 1
|
||||
if next_idx < len(times):
|
||||
end_time = times[next_idx].split("T")[1][:5] if "T" in str(times[next_idx]) else str(times[next_idx])[:5]
|
||||
else:
|
||||
last_t = times[end_idx_inclusive].split("T")[1][:5] if "T" in str(times[end_idx_inclusive]) else str(times[end_idx_inclusive])[:5]
|
||||
end_time = _add_one_hour_hhmm(last_t)
|
||||
if start_time == end_time:
|
||||
end_time = _add_one_hour_hhmm(start_time)
|
||||
return f"{start_time}-{end_time}"
|
||||
|
||||
|
||||
ICE_EVENT_MAX_AIR_TEMP = 2.0 # scarta pericoli invernali se Tmin blocco > questa soglia
|
||||
GELICIDIO_MAX_AIR_TEMP = 1.0
|
||||
|
||||
|
||||
def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, snowfalls=None, rains=None, soil_temps=None, cloud_covers=None, wind_speeds=None):
|
||||
"""Scansiona le 24 ore e trova blocchi di eventi continui."""
|
||||
events = []
|
||||
@@ -838,14 +941,13 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
||||
if cloud_covers is None:
|
||||
cloud_covers = [None] * len(times)
|
||||
if wind_speeds is None:
|
||||
wind_speeds = [None] * len(times)
|
||||
wind_speeds = winds if winds else [None] * len(times)
|
||||
|
||||
# Calcola precipitazioni cumulative delle 3h precedenti per ogni punto
|
||||
# Calcola precipitazioni cumulate nelle 3h precedenti per ogni ora
|
||||
precip_3h_sum = []
|
||||
rain_3h_sum = []
|
||||
snow_3h_sum = []
|
||||
for i in range(len(times)):
|
||||
# Somma delle 3 ore precedenti (i-3, i-2, i-1)
|
||||
start_idx = max(0, i - 3)
|
||||
precip_sum = sum([float(p) if p is not None else 0.0 for p in precip[start_idx:i]])
|
||||
rain_sum = sum([float(r) if r is not None else 0.0 for r in rains[start_idx:i]])
|
||||
@@ -854,7 +956,7 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
||||
rain_3h_sum.append(rain_sum)
|
||||
snow_3h_sum.append(snow_sum)
|
||||
|
||||
# 1. PERICOLI (Ghiaccio, Gelo, Brina) - Logica migliorata allineata a check_ghiaccio.py
|
||||
# 1. PERICOLI (Ghiaccio, Gelo, Brina) - con gate termici anti falsi positivi estivi
|
||||
in_ice = False
|
||||
start_ice = 0
|
||||
ice_type = ""
|
||||
@@ -883,7 +985,7 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
||||
try:
|
||||
hour = int(times[i].split("T")[1].split(":")[0]) if "T" in times[i] else 12
|
||||
is_night = (hour >= 18) or (hour <= 6)
|
||||
except:
|
||||
except Exception:
|
||||
is_night = False
|
||||
|
||||
# Calcola temperatura suolo: usa valore misurato se disponibile, altrimenti stima (1-2°C più fredda)
|
||||
@@ -891,48 +993,41 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
||||
t_soil = t - 1.5 # Approssimazione conservativa
|
||||
|
||||
# Applica raffreddamento radiativo: cielo sereno + notte + vento debole
|
||||
# Riduce la temperatura del suolo di 0.5-1.5°C (come in check_ghiaccio.py)
|
||||
t_soil_adjusted = t_soil
|
||||
if is_night and cloud is not None and cloud < 20.0:
|
||||
if wind is None or wind < 5.0:
|
||||
cooling = 1.5 # Vento molto debole = più raffreddamento
|
||||
cooling = 1.5
|
||||
elif wind < 10.0:
|
||||
cooling = 1.0
|
||||
else:
|
||||
cooling = 0.5
|
||||
t_soil_adjusted = t_soil - cooling
|
||||
|
||||
# Precipitazioni nelle 3h precedenti
|
||||
p_3h = precip_3h_sum[i] if i < len(precip_3h_sum) else 0.0
|
||||
r_3h = rain_3h_sum[i] if i < len(rain_3h_sum) else 0.0
|
||||
s_3h = snow_3h_sum[i] if i < len(snow_3h_sum) else 0.0
|
||||
|
||||
# LOGICA MIGLIORATA (allineata a check_ghiaccio.py):
|
||||
current_ice_condition = None
|
||||
|
||||
# 1. GELICIDIO (Freezing Rain) - priorità massima
|
||||
# 1. GELICIDIO: codice 66/67 richiede anche T aria <= soglia (evita WMO spurii in estate)
|
||||
is_raining_code = (50 <= c <= 69) or (80 <= c <= 82)
|
||||
if c in [66, 67] or (p > 0 and t <= 0 and is_raining_code):
|
||||
if (c in [66, 67] and t <= GELICIDIO_MAX_AIR_TEMP) or (p > 0 and t <= 0 and is_raining_code):
|
||||
current_ice_condition = "🧊☠️ GELICIDIO"
|
||||
|
||||
# 2. Black Ice o Neve Ghiacciata - Precipitazione nelle 3h precedenti + suolo gelato
|
||||
elif p_3h > 0.1 and t_soil_adjusted < 0.0:
|
||||
# Distingue tra neve e pioggia
|
||||
# 2. Black Ice o Neve Ghiacciata
|
||||
elif p_3h > 0.1 and t_soil_adjusted < 0.0 and t <= ICE_EVENT_MAX_AIR_TEMP:
|
||||
has_snow = (s_3h > 0.1) or (snowfall_curr > 0.1)
|
||||
has_rain = (r_3h > 0.1) or (rain_curr > 0.1)
|
||||
if has_snow:
|
||||
current_ice_condition = "⛸️⚠️ Neve ghiacciata (suolo gelato)"
|
||||
elif has_rain:
|
||||
current_ice_condition = "⛸️⚠️ Black Ice (strada bagnata + suolo gelato)"
|
||||
else:
|
||||
current_ice_condition = "⛸️⚠️ Black Ice (strada bagnata + suolo gelato)"
|
||||
|
||||
# 3. BRINA (Hoar Frost) - Suolo <= 0°C e punto di rugiada > suolo ma < 0°C
|
||||
elif p_3h <= 0.1 and t_soil_adjusted <= 0.0 and d is not None:
|
||||
# 3. BRINA
|
||||
elif p_3h <= 0.1 and t_soil_adjusted <= 0.0 and d is not None and t <= ICE_EVENT_MAX_AIR_TEMP:
|
||||
if d > t_soil_adjusted and d < 0.0:
|
||||
current_ice_condition = "⛸️⚠️ GHIACCIO/BRINA"
|
||||
|
||||
# 4. GELATA - Temperatura aria < 0°C (senza altre condizioni)
|
||||
# 4. GELATA
|
||||
elif t < 0:
|
||||
current_ice_condition = "🧊 Gelata"
|
||||
|
||||
@@ -941,28 +1036,51 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
||||
start_ice = i
|
||||
ice_type = current_ice_condition
|
||||
elif (not current_ice_condition and in_ice) or (in_ice and current_ice_condition != ice_type) or (in_ice and i == len(times)-1):
|
||||
end_idx = i if not current_ice_condition else i
|
||||
if end_idx > start_ice:
|
||||
start_time = times[start_ice].split("T")[1][:5]
|
||||
end_time = times[min(end_idx, len(times)-1)].split("T")[1][:5]
|
||||
temp_block = temps[start_ice:min(end_idx+1, len(temps))]
|
||||
temp_block_clean = [t for t in temp_block if t is not None]
|
||||
min_t = min(temp_block_clean) if temp_block_clean else 0
|
||||
|
||||
# Per GHIACCIO/BRINA, verifica che la temperatura minima sia effettivamente sotto/sopra soglia critica
|
||||
# Se la temperatura minima è > 1.5°C, non è un rischio reale
|
||||
if ice_type == "⛸️⚠️ GHIACCIO/BRINA" and min_t > 1.5:
|
||||
# Non segnalare se la temperatura minima è troppo alta
|
||||
pass
|
||||
if not current_ice_condition and in_ice:
|
||||
end_inclusive = i - 1
|
||||
elif in_ice and current_ice_condition and current_ice_condition != ice_type:
|
||||
end_inclusive = i - 1
|
||||
else:
|
||||
events.append(f"{ice_type}: {start_time}-{end_time} (Min: {min_t:.0f}°C)")
|
||||
end_inclusive = i
|
||||
if end_inclusive >= start_ice:
|
||||
hours_str = _format_event_hours(times, start_ice, end_inclusive)
|
||||
temp_block = temps[start_ice:end_inclusive + 1]
|
||||
temp_block_clean = [tv for tv in temp_block if tv is not None]
|
||||
min_t = min(temp_block_clean) if temp_block_clean else 0
|
||||
# Gate termico su tutti i pericoli invernali (non solo brina)
|
||||
if min_t <= ICE_EVENT_MAX_AIR_TEMP:
|
||||
events.append(f"{ice_type}: {hours_str} (Min: {min_t:.0f}°C)")
|
||||
in_ice = False
|
||||
if current_ice_condition:
|
||||
in_ice = True
|
||||
start_ice = i
|
||||
ice_type = current_ice_condition
|
||||
|
||||
# 2. PRECIPITAZIONI
|
||||
# 2. PRECIPITAZIONI — tot_mm è sempre la somma del solo blocco orario (mai il totale giorno)
|
||||
def _precip_type_at(idx):
|
||||
code_val = codes[idx] if idx < len(codes) and codes[idx] is not None else 0
|
||||
t_val = temps[idx] if idx < len(temps) and temps[idx] is not None else None
|
||||
try:
|
||||
code_val = int(code_val) if code_val is not None else 0
|
||||
except (ValueError, TypeError):
|
||||
code_val = 0
|
||||
return get_precip_type(code_val, temp=t_val)
|
||||
|
||||
def _emit_rain(start, end_inclusive, rain_type):
|
||||
if end_inclusive < start:
|
||||
return
|
||||
block_precip = precip[start:end_inclusive + 1]
|
||||
block_precip_clean = [p for p in block_precip if p is not None]
|
||||
tot_mm = sum(float(p) for p in block_precip_clean)
|
||||
if tot_mm <= 0:
|
||||
return
|
||||
hours_str = _format_event_hours(times, start, end_inclusive)
|
||||
avg_intensity = tot_mm / len(block_precip) if block_precip else 0
|
||||
events.append(
|
||||
f"{rain_type} ({get_intensity_label(avg_intensity)}):\n"
|
||||
f" 🕒 {hours_str} | 💧 {tot_mm:.1f}mm"
|
||||
)
|
||||
|
||||
in_rain = False
|
||||
start_idx = 0
|
||||
current_rain_type = ""
|
||||
@@ -970,54 +1088,23 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
||||
for i in range(len(times)):
|
||||
p_val = precip[i] if i < len(precip) and precip[i] is not None else 0
|
||||
is_raining = p_val >= MIN_MM_PER_EVENTO
|
||||
is_last = i == len(times) - 1
|
||||
|
||||
if is_raining and not in_rain:
|
||||
in_rain = True
|
||||
start_idx = i
|
||||
code_val = codes[i] if i < len(codes) and codes[i] is not None else 0
|
||||
try:
|
||||
code_val = int(code_val) if code_val is not None else 0
|
||||
except (ValueError, TypeError):
|
||||
code_val = 0
|
||||
current_rain_type = get_precip_type(code_val)
|
||||
elif in_rain and is_raining and i < len(codes):
|
||||
code_val = codes[i] if codes[i] is not None else 0
|
||||
try:
|
||||
code_val = int(code_val) if code_val is not None else 0
|
||||
except (ValueError, TypeError):
|
||||
code_val = 0
|
||||
new_type = get_precip_type(code_val)
|
||||
current_rain_type = _precip_type_at(i)
|
||||
elif in_rain and is_raining:
|
||||
new_type = _precip_type_at(i)
|
||||
if new_type != current_rain_type:
|
||||
end_idx = i
|
||||
block_precip = precip[start_idx:end_idx] if end_idx <= len(precip) else precip[start_idx:]
|
||||
block_precip_clean = [p for p in block_precip if p is not None]
|
||||
tot_mm = sum(block_precip_clean)
|
||||
start_time = times[start_idx].split("T")[1][:5]
|
||||
end_time = times[end_idx].split("T")[1][:5] if end_idx < len(times) else times[-1].split("T")[1][:5]
|
||||
avg_intensity = tot_mm / len(block_precip) if block_precip else 0
|
||||
|
||||
events.append(
|
||||
f"{current_rain_type} ({get_intensity_label(avg_intensity)}):\n"
|
||||
f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm"
|
||||
)
|
||||
_emit_rain(start_idx, i - 1, current_rain_type)
|
||||
start_idx = i
|
||||
current_rain_type = new_type
|
||||
elif (not is_raining and in_rain) or (in_rain and i == len(times)-1):
|
||||
|
||||
if in_rain and (not is_raining or is_last):
|
||||
end_inclusive = i if is_raining else i - 1
|
||||
_emit_rain(start_idx, end_inclusive, current_rain_type)
|
||||
in_rain = False
|
||||
end_idx = i if not is_raining else i + 1
|
||||
block_precip = precip[start_idx:end_idx] if end_idx <= len(precip) else precip[start_idx:]
|
||||
block_precip_clean = [p for p in block_precip if p is not None]
|
||||
tot_mm = sum(block_precip_clean)
|
||||
|
||||
if tot_mm > 0:
|
||||
start_time = times[start_idx].split("T")[1][:5]
|
||||
end_time = times[min(end_idx-1, len(times)-1)].split("T")[1][:5]
|
||||
avg_intensity = tot_mm / len(block_precip) if block_precip else 0
|
||||
|
||||
events.append(
|
||||
f"{current_rain_type} ({get_intensity_label(avg_intensity)}):\n"
|
||||
f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm"
|
||||
)
|
||||
|
||||
# 3. VENTO
|
||||
if winds:
|
||||
@@ -1027,10 +1114,10 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
||||
if max_wind > SOGLIA_VENTO_KMH:
|
||||
try:
|
||||
peak_idx = winds.index(max_wind)
|
||||
except ValueError:
|
||||
peak_idx = 0
|
||||
peak_time = times[min(peak_idx, len(times)-1)].split("T")[1][:5]
|
||||
events.append(f"💨 Vento Forte: Picco {max_wind:.0f}km/h alle {peak_time}")
|
||||
peak_time = times[peak_idx].split("T")[1][:5]
|
||||
events.append(f"💨 Picco vento: {max_wind:.0f}km/h alle {peak_time}")
|
||||
except (ValueError, IndexError, AttributeError):
|
||||
events.append(f"💨 Picco vento: {max_wind:.0f}km/h")
|
||||
|
||||
return events
|
||||
|
||||
@@ -1042,6 +1129,8 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
|
||||
if trend:
|
||||
if trend["type"] == "fronte_freddo" and trend["intensity"] == "forte":
|
||||
advice.append("❄️ <b>Fronte Freddo in Arrivo:</b> Preparati a temperature in calo significativo. Controlla riscaldamento, proteggi piante sensibili.")
|
||||
elif trend["type"] == "calo_termico" and trend["intensity"] == "forte":
|
||||
advice.append("📉 <b>Calo termico:</b> Massime e/o minime in netto ribasso rispetto all'inizio periodo, senza necessariamente gelo.")
|
||||
elif trend["type"] == "fronte_caldo" and trend["intensity"] == "forte":
|
||||
advice.append("🔥 <b>Ondata di Calore:</b> Temperature in aumento. Mantieni case fresche, idratazione importante, attenzione a persone fragili.")
|
||||
elif trend["type"] == "raffreddamento":
|
||||
@@ -1075,55 +1164,58 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
|
||||
return advice
|
||||
|
||||
def format_detailed_trend_explanation(trend, daily_time_list=None, display_days=DISPLAY_FORECAST_DAYS):
|
||||
"""Genera spiegazione dettagliata del trend temperatura sui giorni in previsione."""
|
||||
"""Genera spiegazione dettagliata del trend su Tmax e Tmin (non temperature medie)."""
|
||||
if not trend:
|
||||
return ""
|
||||
|
||||
explanation = []
|
||||
explanation.append(f"📊 <b>EVOLUZIONE TEMPERATURE ({display_days} GIORNI)</b>\n")
|
||||
|
||||
# Trend principale con spiegazione chiara
|
||||
trend_type = trend["type"]
|
||||
intensity = trend["intensity"]
|
||||
delta = trend['delta']
|
||||
first_avg = trend['first_avg']
|
||||
last_avg = trend['last_avg']
|
||||
first_max = trend["first_max"]
|
||||
last_max = trend["last_max"]
|
||||
first_min = trend["first_min"]
|
||||
last_min = trend["last_min"]
|
||||
delta_max = trend["delta_max"]
|
||||
delta_min = trend["delta_min"]
|
||||
|
||||
if trend_type == "fronte_caldo":
|
||||
trend_desc = "🔥 <b>Fronte Caldo in Arrivo</b>"
|
||||
desc_text = f"Arrivo di aria più calda: temperatura media passerà da {first_avg:.1f}°C a {last_avg:.1f}°C (+{delta:.1f}°C)."
|
||||
elif trend_type == "fronte_freddo":
|
||||
trend_desc = "❄️ <b>Fronte Freddo in Arrivo</b>"
|
||||
desc_text = f"Arrivo di aria più fredda: temperatura media scenderà da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)."
|
||||
elif trend_type == "calo_termico":
|
||||
trend_desc = "📉 <b>Calo Termico</b>"
|
||||
elif trend_type == "riscaldamento":
|
||||
trend_desc = "📈 <b>Riscaldamento Progressivo</b>"
|
||||
desc_text = f"Tendenza al rialzo delle temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C (+{delta:.1f}°C)."
|
||||
elif trend_type == "raffreddamento":
|
||||
trend_desc = "📉 <b>Raffreddamento Progressivo</b>"
|
||||
desc_text = f"Tendenza al ribasso delle temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)."
|
||||
elif trend_type == "stabile":
|
||||
trend_desc = "➡️ <b>Temperature Stabili</b>"
|
||||
desc_text = f"Temperature medie sostanzialmente stabili: da {first_avg:.1f}°C a {last_avg:.1f}°C (variazione {delta:+.1f}°C)."
|
||||
else:
|
||||
trend_desc = "🌡️ <b>Variazione Termica</b>"
|
||||
desc_text = f"Evoluzione temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)."
|
||||
|
||||
intensity_text = " (variazione significativa)" if intensity == "forte" else " (variazione moderata)"
|
||||
explanation.append(f"{trend_desc}{intensity_text}")
|
||||
explanation.append(f"{desc_text}")
|
||||
explanation.append(
|
||||
f"Massime: {first_max:.1f}→{last_max:.1f}°C ({delta_max:+.1f}°C). "
|
||||
f"Minime: {first_min:.1f}→{last_min:.1f}°C ({delta_min:+.1f}°C)."
|
||||
)
|
||||
|
||||
# Aggiungi solo picchi significativi in modo sintetico (entro i giorni in tabella)
|
||||
if trend.get("change_days"):
|
||||
significant_changes = [
|
||||
c for c in trend["change_days"]
|
||||
if abs(c["delta"]) > 3.0 and c["day"] < display_days
|
||||
][:3]
|
||||
][:4]
|
||||
if significant_changes:
|
||||
change_texts = []
|
||||
for change in significant_changes:
|
||||
day_name = format_day_label(change["day"], daily_time_list or [])
|
||||
direction = "↑" if change['delta'] > 0 else "↓"
|
||||
change_texts.append(f"{direction} {day_name}: {change['from']:.0f}°→{change['to']:.0f}°C")
|
||||
direction = "↑" if change["delta"] > 0 else "↓"
|
||||
series_lbl = "max" if change.get("series") == "max" else "min"
|
||||
change_texts.append(
|
||||
f"{direction} {day_name} {series_lbl}: {change['from']:.0f}°→{change['to']:.0f}°C"
|
||||
)
|
||||
if change_texts:
|
||||
explanation.append(f"Picchi: {', '.join(change_texts)}")
|
||||
|
||||
@@ -1146,16 +1238,7 @@ def _apply_unified_precip(hourly: Dict, daily: Dict, casa: bool) -> Tuple[Dict,
|
||||
if icon_d.get("time"):
|
||||
daily = overlay_icon_precip_on_daily(daily, icon_d)
|
||||
hourly["precipitation"] = hourly_precip_series(hourly)
|
||||
totals = daily_precip_from_hourly(hourly)
|
||||
times = daily.get("time") or []
|
||||
psum = list(daily.get("precipitation_sum") or [])
|
||||
while len(psum) < len(times):
|
||||
psum.append(None)
|
||||
for i, t in enumerate(times):
|
||||
d = str(t)[:10]
|
||||
if d in totals:
|
||||
psum[i] = round(totals[d], 2)
|
||||
daily["precipitation_sum"] = psum
|
||||
daily = apply_hourly_daily_precip(daily, hourly)
|
||||
return hourly, daily
|
||||
|
||||
|
||||
@@ -1272,8 +1355,10 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
||||
threshold_mm = 5.0 # Soglia default per pioggia
|
||||
|
||||
if precip_amount > 0.1:
|
||||
# Se snowfall è disponibile e positivo, usa quello (più preciso)
|
||||
if snow_sum_day > 0.1:
|
||||
# Se snowfall è disponibile e positivo, usa quello (più preciso) solo con aria fredda
|
||||
temps_clean_day = [float(t) for t in d_temps_day if t is not None]
|
||||
day_t_min_early = min(temps_clean_day) if temps_clean_day else None
|
||||
if snow_sum_day > 0.1 and day_t_min_early is not None and day_t_min_early <= ICE_EVENT_MAX_AIR_TEMP:
|
||||
# Se c'è neve (anche poca), il simbolo è sempre ❄️ (priorità alla neve)
|
||||
precip_type_symbol = "❄️" # Neve
|
||||
threshold_mm = 0.5 # Soglia più bassa per neve (anche pochi mm sono significativi)
|
||||
@@ -1284,12 +1369,14 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
||||
hail_codes = [96, 99] # Codici WMO per grandine/temporale
|
||||
snow_count = sum(1 for c in d_codes_day if c is not None and int(c) in snow_codes)
|
||||
hail_count = sum(1 for c in d_codes_day if c is not None and int(c) in hail_codes)
|
||||
temps_clean = [float(t) for t in d_temps_day if t is not None]
|
||||
day_t_min = min(temps_clean) if temps_clean else None
|
||||
|
||||
if hail_count > 0:
|
||||
precip_type_symbol = "⛈️" # Grandine/Temporale
|
||||
threshold_mm = 5.0
|
||||
elif snow_count > 0:
|
||||
# Solo se weathercode indica esplicitamente neve
|
||||
elif snow_count > 0 and day_t_min is not None and day_t_min <= ICE_EVENT_MAX_AIR_TEMP:
|
||||
# Weathercode neve solo se aria vicino allo zero (evita falsi ❄️ estivi)
|
||||
precip_type_symbol = "❄️" # Neve
|
||||
threshold_mm = 0.5 # Soglia più bassa per neve
|
||||
|
||||
@@ -1493,8 +1580,9 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
||||
pass # Gestito separatamente per l'icona meteo
|
||||
|
||||
if precip_sum > 0.1:
|
||||
cold_enough_day = t_min <= ICE_EVENT_MAX_AIR_TEMP
|
||||
# Priorità 1: Se sta nevicando (snowfall > 0) e c'è manto nevoso, considera entrambi
|
||||
if has_snow_depth_data and max_snow_depth > 0:
|
||||
if cold_enough_day and has_snow_depth_data and max_snow_depth > 0:
|
||||
# C'è sia neve in caduta che manto nevoso persistente
|
||||
if rain_sum > 0.1 or showers_sum > 0.1:
|
||||
precip_type = "mixed" # Neve + pioggia/temporali
|
||||
@@ -1505,7 +1593,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
||||
# Il tipo di precipitazione resta quello basato su snowfall/rain
|
||||
pass
|
||||
# Priorità 2: Usa dati daily se disponibili
|
||||
elif snowfall_sum > 0.1:
|
||||
elif cold_enough_day and snowfall_sum > 0.1:
|
||||
# C'è neve significativa
|
||||
if snowfall_sum >= precip_sum * 0.5:
|
||||
precip_type = "snow"
|
||||
@@ -1527,7 +1615,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
||||
else:
|
||||
# Fallback: usa dati hourly se daily non disponibili
|
||||
snow_sum_day = sum([float(s) for s in d_snow if s is not None]) if d_snow else 0.0
|
||||
if snow_sum_day > 0.1:
|
||||
if snow_sum_day > 0.1 and t_min <= ICE_EVENT_MAX_AIR_TEMP:
|
||||
if snow_sum_day >= precip_sum * 0.5:
|
||||
precip_type = "snow"
|
||||
else:
|
||||
@@ -1543,7 +1631,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
||||
|
||||
if hail_count > 0:
|
||||
precip_type = "hail"
|
||||
elif snow_count > rain_count:
|
||||
elif snow_count > rain_count and t_min is not None and float(t_min) <= ICE_EVENT_MAX_AIR_TEMP:
|
||||
precip_type = "snow"
|
||||
else:
|
||||
precip_type = "rain"
|
||||
@@ -1563,8 +1651,8 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
||||
weather_icon = "🌨️" # Precipitazione mista
|
||||
else:
|
||||
weather_icon = "🌧️" # Pioggia
|
||||
elif has_snow_depth_data and max_snow_depth > 0:
|
||||
# C'è manto nevoso persistente anche senza precipitazioni
|
||||
elif has_snow_depth_data and max_snow_depth > 0 and t_min <= ICE_EVENT_MAX_AIR_TEMP:
|
||||
# C'è manto nevoso persistente anche senza precipitazioni (solo se aria abbastanza fredda)
|
||||
# Mostra icona neve anche se non sta nevicando
|
||||
weather_icon = "❄️" # Manto nevoso presente
|
||||
elif t_min < 0:
|
||||
@@ -1698,23 +1786,36 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
||||
if day_info['precip_sum'] > 0.1:
|
||||
# Caratterizza usando dati daily se disponibili
|
||||
precip_parts = []
|
||||
snow_sum = day_info.get("snowfall_sum", 0) or 0
|
||||
rain_sum = day_info.get("rain_sum", 0) or 0
|
||||
showers_sum = day_info.get("showers_sum", 0) or 0
|
||||
precip_sum = day_info.get("precip_sum", 0) or 0
|
||||
|
||||
# Neve
|
||||
if day_info.get('snowfall_sum', 0) > 0.1:
|
||||
precip_parts.append(f"❄️ {day_info['snowfall_sum']:.1f}cm")
|
||||
if snow_sum > 0.1 and day_info["t_min"] <= ICE_EVENT_MAX_AIR_TEMP:
|
||||
precip_parts.append(f"❄️ {snow_sum:.1f}cm")
|
||||
|
||||
# Pioggia
|
||||
if day_info.get('rain_sum', 0) > 0.1:
|
||||
precip_parts.append(f"🌧️ {day_info['rain_sum']:.1f}mm")
|
||||
|
||||
# Temporali (showers)
|
||||
if day_info.get('showers_sum', 0) > 0.1:
|
||||
precip_parts.append(f"⛈️ {day_info['showers_sum']:.1f}mm")
|
||||
|
||||
# Se non abbiamo dati daily dettagliati, usa il tipo generale
|
||||
if not precip_parts:
|
||||
precip_symbol = "❄️" if day_info['precip_type'] == "snow" else "⛈️" if day_info['precip_type'] in ("hail", "thunderstorms") else "🌨️" if day_info['precip_type'] == "mixed" else "🌧️"
|
||||
precip_parts.append(f"{precip_symbol} {day_info['precip_sum']:.1f}mm")
|
||||
overlapping = (
|
||||
rain_sum > 0.1 and showers_sum > 0.1 and (
|
||||
abs(rain_sum - precip_sum) < 0.25
|
||||
or abs(showers_sum - precip_sum) < 0.25
|
||||
or (rain_sum + showers_sum) > precip_sum + 0.3
|
||||
)
|
||||
)
|
||||
if overlapping or (rain_sum <= 0.1 and showers_sum <= 0.1):
|
||||
if precip_sum > 0.1:
|
||||
precip_symbol = (
|
||||
"❄️" if day_info["precip_type"] == "snow"
|
||||
else "⛈️" if day_info["precip_type"] in ("hail", "thunderstorms")
|
||||
else "🌨️" if day_info["precip_type"] == "mixed"
|
||||
else "🌧️"
|
||||
)
|
||||
if not (day_info["precip_type"] == "snow" and snow_sum > 0.1 and day_info["t_min"] <= ICE_EVENT_MAX_AIR_TEMP):
|
||||
precip_parts.append(f"{precip_symbol} {precip_sum:.1f}mm")
|
||||
else:
|
||||
if rain_sum > 0.1:
|
||||
precip_parts.append(f"🌧️ {rain_sum:.1f}mm")
|
||||
if showers_sum > 0.1:
|
||||
precip_parts.append(f"⛈️ {showers_sum:.1f}mm")
|
||||
|
||||
line += f" | {' + '.join(precip_parts)}"
|
||||
|
||||
@@ -1738,7 +1839,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
||||
elif snow_depth_avg is not None and snow_depth_avg > 0:
|
||||
snow_depth_end = snow_depth_avg # Usa la media come fallback
|
||||
|
||||
if snow_depth_end is not None and snow_depth_end > 0:
|
||||
if snow_depth_end is not None and snow_depth_end > 0 and day_info['t_min'] <= ICE_EVENT_MAX_AIR_TEMP:
|
||||
snow_depth_str = f"❄️ Manto nevoso: {snow_depth_end:.1f} cm"
|
||||
# Mostra evoluzione rispetto al giorno precedente
|
||||
if prev_snow_depth_end is not None:
|
||||
|
||||
@@ -273,15 +273,12 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
||||
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
|
||||
"""
|
||||
try:
|
||||
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
|
||||
from telegram_gate import telegram_alerts_enabled
|
||||
except ImportError:
|
||||
telegram_alerts_enabled = lambda: True # type: ignore
|
||||
mirror_alert_to_web = lambda *a, **k: False # type: ignore
|
||||
|
||||
if not telegram_alerts_enabled():
|
||||
LOGGER.info("Telegram sospeso: skip severe_weather")
|
||||
if message_html:
|
||||
mirror_alert_to_web(message_html, "severe_weather", "warning", is_html=True)
|
||||
return False
|
||||
|
||||
token = load_bot_token()
|
||||
@@ -315,15 +312,6 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
||||
except Exception as e:
|
||||
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
|
||||
|
||||
if sent_ok:
|
||||
try:
|
||||
import sys
|
||||
sys.path.insert(0, "/home/daniely/docker/shared")
|
||||
from loogle_core.alert_dispatcher import mirror_to_web
|
||||
mirror_to_web(message_html, "severe_weather", "warning", is_html=True)
|
||||
except Exception as e:
|
||||
LOGGER.debug("Web dispatch failed: %s", e)
|
||||
|
||||
return sent_ok
|
||||
|
||||
|
||||
@@ -1859,10 +1847,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
msg = f"{headline}\n{meta}\n{body}{footer}"
|
||||
|
||||
ok = telegram_send_html(msg, chat_ids=chat_ids)
|
||||
if ok:
|
||||
LOGGER.info("Alert sent successfully.")
|
||||
else:
|
||||
LOGGER.warning("Alert NOT sent (token missing or Telegram error).")
|
||||
web_ok = False
|
||||
|
||||
# IMPORTANTE: Imposta alert_active = True solo se c'è una vera allerta,
|
||||
# non se è solo un messaggio informativo in modalità debug
|
||||
@@ -1879,10 +1864,23 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
state["alert_active"] = True
|
||||
state["last_alert_type"] = alert_types if alert_types else None
|
||||
state["last_alert_time"] = now.isoformat()
|
||||
if ok:
|
||||
try:
|
||||
from webapp_alert import publish_web_alert
|
||||
web_ok = bool(publish_web_alert(msg, "severe_weather", "warning", is_html=True, state=state))
|
||||
except Exception as e:
|
||||
LOGGER.debug("Web summary failed: %s", e)
|
||||
if ok or web_ok:
|
||||
record_notify_message(now, state, alert_signature)
|
||||
save_state(state)
|
||||
|
||||
if ok:
|
||||
LOGGER.info("Alert sent successfully (Telegram).")
|
||||
elif web_ok:
|
||||
LOGGER.info("Alert published on WebApp.")
|
||||
else:
|
||||
LOGGER.warning("Alert NOT delivered (Telegram/WebApp).")
|
||||
|
||||
if debug_message_only:
|
||||
# In debug mode senza vere allerte, non modificare alert_active
|
||||
LOGGER.debug("[DEBUG MODE] Messaggio inviato ma alert_active non modificato (nessuna vera allerta)")
|
||||
return
|
||||
@@ -1942,7 +1940,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
if ok:
|
||||
LOGGER.info("All-clear sent successfully.")
|
||||
else:
|
||||
LOGGER.warning("All-clear NOT sent (token missing or Telegram error).")
|
||||
LOGGER.info("All-clear Telegram skip/fail (WebApp primaria se attiva).")
|
||||
|
||||
state = {
|
||||
"alert_active": False,
|
||||
|
||||
@@ -814,12 +814,30 @@ def analyze_all_locations(debug_mode: bool = False) -> None:
|
||||
return
|
||||
|
||||
ok = telegram_send_html(msg, chat_ids=[TELEGRAM_CHAT_IDS[0]] if debug_mode else None)
|
||||
if ok:
|
||||
LOGGER.info("Alert inviato (%s) per %d località significative", category, len(significant_locations))
|
||||
else:
|
||||
LOGGER.warning("Alert NON inviato (token missing o errore Telegram)")
|
||||
web_ok = False
|
||||
try:
|
||||
from webapp_alert import publish_web_alert
|
||||
web_ok = bool(publish_web_alert(msg, "severe_circondario", "warning", is_html=True, state=state))
|
||||
except Exception as e:
|
||||
LOGGER.debug("Web summary failed: %s", e)
|
||||
|
||||
if ok and not debug_mode:
|
||||
delivered = bool(ok or web_ok)
|
||||
if ok:
|
||||
LOGGER.info("Alert inviato su Telegram (%s) per %d località significative", category, len(significant_locations))
|
||||
elif web_ok:
|
||||
LOGGER.info("Alert pubblicato su WebApp (%s) per %d località significative", category, len(significant_locations))
|
||||
else:
|
||||
try:
|
||||
from telegram_gate import telegram_alerts_enabled
|
||||
suspended = not telegram_alerts_enabled()
|
||||
except Exception:
|
||||
suspended = False
|
||||
if suspended:
|
||||
LOGGER.warning("Alert NON pubblicato su WebApp (Telegram sospeso)")
|
||||
else:
|
||||
LOGGER.warning("Alert NON inviato (errore Telegram o WebApp)")
|
||||
|
||||
if delivered and not debug_mode:
|
||||
record_notify(category, now, state)
|
||||
state["last_signature"] = signature
|
||||
state["last_signature_date"] = today
|
||||
|
||||
@@ -135,6 +135,34 @@ def hhmm(dt: datetime.datetime) -> str:
|
||||
return dt.strftime("%H:%M")
|
||||
|
||||
|
||||
_WEEKDAYS_IT = ("lun", "mar", "mer", "gio", "ven", "sab", "dom")
|
||||
|
||||
|
||||
def format_clock(dt: datetime.datetime, ref: Optional[datetime.datetime] = None) -> str:
|
||||
"""HH:MM, con giorno corto se diverso da ref (default: oggi locale)."""
|
||||
ref = ref or now_local()
|
||||
label = hhmm(dt)
|
||||
if dt.date() != ref.date():
|
||||
return f"{_WEEKDAYS_IT[dt.weekday()]} {label}"
|
||||
return label
|
||||
|
||||
|
||||
def format_fascia(
|
||||
start: Optional[datetime.datetime],
|
||||
end: Optional[datetime.datetime],
|
||||
ref: Optional[datetime.datetime] = None,
|
||||
) -> str:
|
||||
"""Fascia leggibile ~HH:MM–~HH:MM (con giorno se serve)."""
|
||||
if start is None:
|
||||
return "—"
|
||||
ref = ref or now_local()
|
||||
a = format_clock(start, ref)
|
||||
if end is None or end == start:
|
||||
return f"~{a}"
|
||||
b = format_clock(end, ref)
|
||||
return f"~{a}–~{b}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Telegram
|
||||
# =============================================================================
|
||||
@@ -202,20 +230,100 @@ def load_state() -> Dict:
|
||||
return default
|
||||
|
||||
|
||||
def save_state(alert_active: bool, signature: str) -> None:
|
||||
def save_state(alert_active: bool, signature: str, detail: Optional[Dict] = None) -> None:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
||||
payload: Dict = {
|
||||
"alert_active": alert_active,
|
||||
"signature": signature,
|
||||
"updated": now_local().isoformat(),
|
||||
}
|
||||
if detail:
|
||||
payload.update(detail)
|
||||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{"alert_active": alert_active, "signature": signature, "updated": now_local().isoformat()},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
LOGGER.exception("State write error: %s", e)
|
||||
|
||||
|
||||
def build_plain_summary(bo_alerts: Dict, route_alerts: List[Dict]) -> str:
|
||||
"""Testo leggibile per WebApp (dove / eventi / quando)."""
|
||||
any_snow = bool(bo_alerts.get("snow_alert")) or any(x.get("snow_alert") for x in route_alerts)
|
||||
any_rain = bool(bo_alerts.get("rain_alert")) or any(x.get("rain_alert") for x in route_alerts)
|
||||
soglie: List[str] = []
|
||||
if any_snow:
|
||||
soglie.append(f"neve ≥{PERSIST_HOURS}h consecutive")
|
||||
if any_rain:
|
||||
soglie.append(
|
||||
f"pioggia 3h ≥{SOGLIA_PIOGGIA_3H_MM:.0f} mm per ≥{PERSIST_HOURS}h"
|
||||
)
|
||||
|
||||
lines: List[str] = [
|
||||
f"Percorso scuola Bologna ↔ rientro · prossime {HOURS_AHEAD}h",
|
||||
]
|
||||
if soglie:
|
||||
lines.append("Soglie: " + " · ".join(soglie))
|
||||
lines.extend([
|
||||
"",
|
||||
"A Bologna:",
|
||||
])
|
||||
if bo_alerts.get("snow_alert"):
|
||||
fascia = bo_alerts.get("snow_fascia") or f"~{bo_alerts.get('snow_run_time') or '—'}"
|
||||
lines.append(
|
||||
f"• Neve {fascia} "
|
||||
f"(12h {bo_alerts.get('snow_12h', 0):.1f} cm · 24h {bo_alerts.get('snow_24h', 0):.1f} cm)"
|
||||
)
|
||||
if bo_alerts.get("rain_alert"):
|
||||
r3 = float(bo_alerts.get("rain3_max") or 0)
|
||||
fascia = bo_alerts.get("rain_fascia") or f"~{bo_alerts.get('rain_persist_time') or '—'}"
|
||||
lines.append(f"• Pioggia forte {fascia} (max 3h {r3:.1f} mm)")
|
||||
if not bo_alerts.get("snow_alert") and not bo_alerts.get("rain_alert"):
|
||||
lines.append("• Nessuna criticità persistente (trigger Bologna assente nel dettaglio punti)")
|
||||
|
||||
issues = [x for x in route_alerts if x.get("snow_alert") or x.get("rain_alert")]
|
||||
lines.append("")
|
||||
lines.append("Caselli A14 / tratto:")
|
||||
if not issues:
|
||||
lines.append("• Nessuna criticità lungo il percorso")
|
||||
else:
|
||||
for x in issues:
|
||||
parts: List[str] = []
|
||||
if x.get("snow_alert"):
|
||||
fascia = x.get("snow_fascia") or f"~{x.get('snow_run_time') or '—'}"
|
||||
parts.append(f"neve {fascia} (24h {x.get('snow_24h', 0):.1f} cm)")
|
||||
if x.get("rain_alert"):
|
||||
r3 = float(x.get("rain3_max") or 0)
|
||||
fascia = x.get("rain_fascia") or f"~{x.get('rain_persist_time') or '—'}"
|
||||
parts.append(f"pioggia {fascia} (max 3h {r3:.1f} mm)")
|
||||
lines.append(f"• {x.get('name', '?')}: " + " | ".join(parts))
|
||||
|
||||
lines.append("")
|
||||
lines.append("Fonte: Open-Meteo (AROME / confronto ICON IT)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def first_event_time(bo_alerts: Dict, route_alerts: List[Dict]) -> Optional[str]:
|
||||
candidates: List[str] = []
|
||||
for src in [bo_alerts, *route_alerts]:
|
||||
if src.get("snow_alert") and src.get("snow_run_time"):
|
||||
candidates.append(str(src["snow_run_time"]))
|
||||
if src.get("rain_alert") and src.get("rain_persist_time"):
|
||||
candidates.append(str(src["rain_persist_time"]))
|
||||
return min(candidates) if candidates else None
|
||||
|
||||
|
||||
def notify_webapp(title: str, body: str, severity: str = "warning") -> None:
|
||||
try:
|
||||
sys_path = "/home/daniely/docker/shared"
|
||||
if sys_path not in __import__("sys").path:
|
||||
__import__("sys").path.insert(0, sys_path)
|
||||
from loogle_core.alert_dispatcher import dispatch_alert
|
||||
|
||||
dispatch_alert(title, body, category="student", severity=severity)
|
||||
except Exception as e:
|
||||
LOGGER.warning("WebApp notify fallita: %s", e)
|
||||
|
||||
|
||||
def get_forecast(session: requests.Session, lat: float, lon: float, model: str) -> Optional[Dict]:
|
||||
params = {
|
||||
"latitude": lat,
|
||||
@@ -357,10 +465,28 @@ def rolling_sum_3h(values: List[float]) -> List[float]:
|
||||
return out
|
||||
|
||||
|
||||
def first_persistent_run(values: List[float], threshold: float, persist: int) -> Tuple[bool, int, int, float]:
|
||||
def first_persistent_run(
|
||||
values: List[float], threshold: float, persist: int
|
||||
) -> Tuple[bool, int, int, float]:
|
||||
"""Prima run continua con valori >= soglia e lunghezza >= persist.
|
||||
|
||||
Ritorna (ok, start_idx, end_idx inclusivo, run_max). La run viene
|
||||
estesa fino alla fine (non si ferma al minimo persist).
|
||||
"""
|
||||
consec = 0
|
||||
run_start = -1
|
||||
run_max = 0.0
|
||||
found_start = -1
|
||||
found_end = -1
|
||||
found_max = 0.0
|
||||
|
||||
def _flush() -> None:
|
||||
nonlocal found_start, found_end, found_max
|
||||
if consec >= persist and found_start < 0:
|
||||
found_start = run_start
|
||||
found_end = run_start + consec - 1
|
||||
found_max = run_max
|
||||
|
||||
for i, v in enumerate(values):
|
||||
vv = float(v) if v is not None else 0.0
|
||||
if vv >= threshold:
|
||||
@@ -370,30 +496,19 @@ def first_persistent_run(values: List[float], threshold: float, persist: int) ->
|
||||
else:
|
||||
run_max = max(run_max, vv)
|
||||
consec += 1
|
||||
if consec >= persist:
|
||||
return True, run_start, consec, run_max
|
||||
else:
|
||||
_flush()
|
||||
if found_start >= 0:
|
||||
break
|
||||
consec = 0
|
||||
return False, -1, 0, 0.0
|
||||
|
||||
|
||||
def max_consecutive_gt(values: List[float], eps: float) -> Tuple[int, int]:
|
||||
best_len = 0
|
||||
best_start = -1
|
||||
consec = 0
|
||||
start = -1
|
||||
for i, v in enumerate(values):
|
||||
vv = float(v) if v is not None else 0.0
|
||||
if vv > eps:
|
||||
if consec == 0:
|
||||
start = i
|
||||
consec += 1
|
||||
if consec > best_len:
|
||||
best_len = consec
|
||||
best_start = start
|
||||
run_start = -1
|
||||
run_max = 0.0
|
||||
else:
|
||||
consec = 0
|
||||
return best_len, best_start
|
||||
_flush()
|
||||
|
||||
if found_start >= 0:
|
||||
return True, found_start, found_end, found_max
|
||||
return False, -1, -1, 0.0
|
||||
|
||||
|
||||
def compute_stats(data: Dict) -> Optional[Dict]:
|
||||
@@ -428,120 +543,67 @@ def compute_stats(data: Dict) -> Optional[Dict]:
|
||||
rain3_max_idx = rain3.index(rain3_max) if rain3 else -1
|
||||
rain3_max_time = hhmm(dt_w[rain3_max_idx]) if (rain3_max_idx >= 0 and rain3_max_idx < len(dt_w)) else ""
|
||||
|
||||
rain_persist_ok, rain_run_start, rain_run_len, rain_run_max = first_persistent_run(
|
||||
rain_persist_ok, rain3_start, rain3_end, rain_run_max = first_persistent_run(
|
||||
rain3, SOGLIA_PIOGGIA_3H_MM, PERSIST_HOURS
|
||||
)
|
||||
rain_persist_time = hhmm(dt_w[rain_run_start]) if (rain_persist_ok and rain_run_start < len(dt_w)) else ""
|
||||
rain_run_len = (rain3_end - rain3_start + 1) if rain_persist_ok else 0
|
||||
rain_persist_dt_start: Optional[datetime.datetime] = None
|
||||
rain_persist_dt_end: Optional[datetime.datetime] = None
|
||||
rain_persist_time = ""
|
||||
rain_persist_end_time = ""
|
||||
rain_fascia = ""
|
||||
if rain_persist_ok and 0 <= rain3_start < len(dt_w):
|
||||
# Fascia = ore di calendario coperte dalle finestre rolling (ultima = end+2)
|
||||
covered_end_idx = min(rain3_end + 2, len(dt_w) - 1)
|
||||
rain_persist_dt_start = dt_w[rain3_start]
|
||||
rain_persist_dt_end = dt_w[covered_end_idx]
|
||||
rain_persist_time = format_clock(rain_persist_dt_start)
|
||||
rain_persist_end_time = format_clock(rain_persist_dt_end)
|
||||
rain_fascia = format_fascia(rain_persist_dt_start, rain_persist_dt_end)
|
||||
|
||||
# Analizza evento pioggia completa (48h): rileva inizio e calcola durata e accumulo totale
|
||||
rain_start_idx = None
|
||||
rain_end_idx = None
|
||||
total_rain_accumulation = 0.0
|
||||
rain_duration_hours = 0.0
|
||||
max_rain_intensity = 0.0
|
||||
|
||||
# Codici meteo che indicano pioggia (WMO)
|
||||
RAIN_WEATHER_CODES = [61, 63, 65, 66, 67, 80, 81, 82]
|
||||
|
||||
# Trova inizio evento pioggia (prima occorrenza con precipitation > 0 OPPURE weathercode pioggia)
|
||||
# Estendi l'analisi a 48 ore se disponibile
|
||||
extended_end_idx = min(start_idx + 48, n) # Estendi a 48 ore
|
||||
precip_extended = precip[start_idx:extended_end_idx]
|
||||
weathercode_extended = [int(x) if x is not None else None for x in weathercode[start_idx:extended_end_idx]] if len(weathercode) > start_idx else []
|
||||
|
||||
for i, (p_val, code) in enumerate(zip(precip_extended, weathercode_extended if len(weathercode_extended) == len(precip_extended) else [None] * len(precip_extended))):
|
||||
p_val_float = float(p_val) if p_val is not None else 0.0
|
||||
is_rain = (p_val_float > 0.0) or (code is not None and code in RAIN_WEATHER_CODES)
|
||||
if is_rain and rain_start_idx is None:
|
||||
rain_start_idx = i
|
||||
break
|
||||
|
||||
# Se trovato inizio, calcola durata e accumulo totale su 48 ore
|
||||
if rain_start_idx is not None:
|
||||
# Trova fine evento pioggia (ultima occorrenza con pioggia)
|
||||
for i in range(len(precip_extended) - 1, rain_start_idx - 1, -1):
|
||||
p_val = precip_extended[i] if i < len(precip_extended) else None
|
||||
code = weathercode_extended[i] if i < len(weathercode_extended) else None
|
||||
p_val_float = float(p_val) if p_val is not None else 0.0
|
||||
is_rain = (p_val_float > 0.0) or (code is not None and code in RAIN_WEATHER_CODES)
|
||||
if is_rain:
|
||||
rain_end_idx = i
|
||||
break
|
||||
|
||||
if rain_end_idx is not None:
|
||||
# Calcola durata
|
||||
times_extended = times[start_idx:extended_end_idx]
|
||||
dt_extended = [parse_time_to_local(t) for t in times_extended]
|
||||
if rain_end_idx < len(dt_extended) and rain_start_idx < len(dt_extended):
|
||||
rain_duration_hours = (dt_extended[rain_end_idx] - dt_extended[rain_start_idx]).total_seconds() / 3600.0
|
||||
# Calcola accumulo totale (somma di tutti i precipitation > 0)
|
||||
total_rain_accumulation = sum(float(p) for p in precip_extended[rain_start_idx:rain_end_idx+1] if p is not None and float(p) > 0.0)
|
||||
# Calcola intensità massima oraria
|
||||
max_rain_intensity = max((float(p) for p in precip_extended[rain_start_idx:rain_end_idx+1] if p is not None), default=0.0)
|
||||
|
||||
# Analizza nevicata completa (48h): rileva inizio usando snowfall > 0 OPPURE weathercode
|
||||
# Calcola durata e accumulo totale
|
||||
snow_start_idx = None
|
||||
snow_end_idx = None
|
||||
total_snow_accumulation = 0.0
|
||||
snow_duration_hours = 0.0
|
||||
|
||||
# Trova inizio nevicata (prima occorrenza con snowfall > 0 OPPURE weathercode neve)
|
||||
for i, (s_val, code) in enumerate(zip(snow_w, weathercode_w if len(weathercode_w) == len(snow_w) else [None] * len(snow_w))):
|
||||
is_snow = (s_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
|
||||
if is_snow and snow_start_idx is None:
|
||||
snow_start_idx = i
|
||||
break
|
||||
|
||||
# Se trovato inizio, calcola durata e accumulo totale
|
||||
if snow_start_idx is not None:
|
||||
# Trova fine nevicata (ultima occorrenza con neve)
|
||||
for i in range(len(snow_w) - 1, snow_start_idx - 1, -1):
|
||||
s_val = snow_w[i]
|
||||
# Flag orari neve: accumulo orario sopra eps OPPURE weathercode neve
|
||||
snow_flags: List[float] = []
|
||||
for i, s_val in enumerate(snow_w):
|
||||
code = weathercode_w[i] if i < len(weathercode_w) else None
|
||||
is_snow = (s_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
|
||||
if is_snow:
|
||||
snow_end_idx = i
|
||||
break
|
||||
is_snow = (s_val > SNOW_HOURLY_EPS_CM) or (
|
||||
code is not None and code in SNOW_WEATHER_CODES
|
||||
)
|
||||
snow_flags.append(1.0 if is_snow else 0.0)
|
||||
|
||||
if snow_end_idx is not None:
|
||||
# Calcola durata
|
||||
snow_duration_hours = (dt_w[snow_end_idx] - dt_w[snow_start_idx]).total_seconds() / 3600.0
|
||||
# Calcola accumulo totale (somma di tutti i snowfall > 0)
|
||||
total_snow_accumulation = sum(s for s in snow_w[snow_start_idx:snow_end_idx+1] if s > 0.0)
|
||||
snow_ok, snow_run_start, snow_run_end, _ = first_persistent_run(
|
||||
snow_flags, 1.0, PERSIST_HOURS
|
||||
)
|
||||
snow_run_len = (snow_run_end - snow_run_start + 1) if snow_ok else 0
|
||||
snow_persist_dt_start: Optional[datetime.datetime] = None
|
||||
snow_persist_dt_end: Optional[datetime.datetime] = None
|
||||
snow_run_time = ""
|
||||
snow_run_end_time = ""
|
||||
snow_fascia = ""
|
||||
if snow_ok and 0 <= snow_run_start < len(dt_w) and 0 <= snow_run_end < len(dt_w):
|
||||
snow_persist_dt_start = dt_w[snow_run_start]
|
||||
snow_persist_dt_end = dt_w[snow_run_end]
|
||||
snow_run_time = format_clock(snow_persist_dt_start)
|
||||
snow_run_end_time = format_clock(snow_persist_dt_end)
|
||||
snow_fascia = format_fascia(snow_persist_dt_start, snow_persist_dt_end)
|
||||
|
||||
# Per compatibilità con logica esistente
|
||||
snow_run_len, snow_run_start = max_consecutive_gt(snow_w, eps=SNOW_HOURLY_EPS_CM)
|
||||
snow_run_time = hhmm(dt_w[snow_run_start]) if (snow_run_start >= 0 and snow_run_start < len(dt_w)) else ""
|
||||
|
||||
# Se trovato inizio nevicata, usa quello invece del run
|
||||
if snow_start_idx is not None:
|
||||
snow_run_time = hhmm(dt_w[snow_start_idx])
|
||||
# Durata minima per alert: almeno 2 ore
|
||||
if snow_duration_hours >= PERSIST_HOURS:
|
||||
snow_run_len = int(snow_duration_hours)
|
||||
else:
|
||||
snow_run_len = 0 # Durata troppo breve
|
||||
|
||||
snow_12h = sum(s for s in snow_w[:min(12, len(snow_w))] if s > 0.0)
|
||||
snow_24h = sum(s for s in snow_w[:min(24, len(snow_w))] if s > 0.0)
|
||||
snow_12h = sum(s for s in snow_w[: min(12, len(snow_w))] if s > 0.0)
|
||||
snow_24h = sum(s for s in snow_w[: min(24, len(snow_w))] if s > 0.0)
|
||||
|
||||
return {
|
||||
"rain3_max": float(rain3_max),
|
||||
"rain3_max_time": rain3_max_time,
|
||||
"rain_persist_ok": bool(rain_persist_ok),
|
||||
"rain_persist_time": rain_persist_time,
|
||||
"rain_persist_end_time": rain_persist_end_time,
|
||||
"rain_fascia": rain_fascia,
|
||||
"rain_persist_run_max": float(rain_run_max),
|
||||
"rain_persist_run_len": int(rain_run_len),
|
||||
"rain_duration_hours": float(rain_duration_hours),
|
||||
"total_rain_accumulation_mm": float(total_rain_accumulation),
|
||||
"max_rain_intensity_mm_h": float(max_rain_intensity),
|
||||
"snow_run_len": int(snow_run_len),
|
||||
"snow_run_time": snow_run_time,
|
||||
"snow_run_end_time": snow_run_end_time,
|
||||
"snow_fascia": snow_fascia,
|
||||
"snow_12h": float(snow_12h),
|
||||
"snow_24h": float(snow_24h),
|
||||
"snow_duration_hours": float(snow_duration_hours),
|
||||
"total_snow_accumulation_cm": float(total_snow_accumulation),
|
||||
}
|
||||
|
||||
|
||||
@@ -556,14 +618,15 @@ def point_alerts(point_name: str, stats: Dict) -> Dict:
|
||||
"snow_24h": stats["snow_24h"],
|
||||
"snow_run_len": stats["snow_run_len"],
|
||||
"snow_run_time": stats["snow_run_time"],
|
||||
"snow_run_end_time": stats.get("snow_run_end_time", ""),
|
||||
"snow_fascia": stats.get("snow_fascia", ""),
|
||||
"rain3_max": stats["rain3_max"],
|
||||
"rain3_max_time": stats["rain3_max_time"],
|
||||
"rain_persist_time": stats["rain_persist_time"],
|
||||
"rain_persist_end_time": stats.get("rain_persist_end_time", ""),
|
||||
"rain_fascia": stats.get("rain_fascia", ""),
|
||||
"rain_persist_run_max": stats["rain_persist_run_max"],
|
||||
"rain_persist_run_len": stats["rain_persist_run_len"],
|
||||
"rain_duration_hours": stats.get("rain_duration_hours", 0.0),
|
||||
"total_rain_accumulation_mm": stats.get("total_rain_accumulation_mm", 0.0),
|
||||
"max_rain_intensity_mm_h": stats.get("max_rain_intensity_mm_h", 0.0),
|
||||
}
|
||||
|
||||
|
||||
@@ -660,7 +723,10 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
|
||||
msg.append("🎓 <b>A BOLOGNA</b>")
|
||||
bo_comp = comparisons.get(bo["name"])
|
||||
if bo_alerts["snow_alert"]:
|
||||
msg.append(f"❄️ Neve (≥{PERSIST_HOURS}h) da ~<b>{html.escape(bo_alerts['snow_run_time'] or '—')}</b> (run ~{bo_alerts['snow_run_len']}h).")
|
||||
fascia = bo_alerts.get("snow_fascia") or f"~{bo_alerts.get('snow_run_time') or '—'}"
|
||||
msg.append(
|
||||
f"❄️ Neve (≥{PERSIST_HOURS}h) <b>{html.escape(fascia)}</b>."
|
||||
)
|
||||
msg.append(f"• Accumulo: 12h <b>{bo_alerts['snow_12h']:.1f} cm</b> | 24h <b>{bo_alerts['snow_24h']:.1f} cm</b>")
|
||||
if bo_comp and bo_comp.get("snow"):
|
||||
comp = bo_comp["snow"]
|
||||
@@ -670,13 +736,12 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
|
||||
msg.append(f"❄️ Neve: nessuna persistenza ≥ {PERSIST_HOURS}h (24h {bo_alerts['snow_24h']:.1f} cm).")
|
||||
|
||||
if bo_alerts["rain_alert"]:
|
||||
rain_duration = bo_alerts.get("rain_duration_hours", 0.0)
|
||||
total_rain = bo_alerts.get("total_rain_accumulation_mm", 0.0)
|
||||
max_intensity = bo_alerts.get("max_rain_intensity_mm_h", 0.0)
|
||||
|
||||
msg.append(f"🌧️ Pioggia molto forte (3h ≥ {SOGLIA_PIOGGIA_3H_MM:.0f} mm, ≥{PERSIST_HOURS}h) da ~<b>{html.escape(bo_alerts['rain_persist_time'] or '—')}</b>.")
|
||||
if rain_duration > 0:
|
||||
msg.append(f"⏱️ <b>Durata totale evento (48h):</b> ~{rain_duration:.0f} ore | <b>Accumulo totale:</b> ~{total_rain:.1f} mm | <b>Intensità max:</b> {max_intensity:.1f} mm/h")
|
||||
fascia = bo_alerts.get("rain_fascia") or f"~{bo_alerts.get('rain_persist_time') or '—'}"
|
||||
msg.append(
|
||||
f"🌧️ Pioggia molto forte (3h ≥ {SOGLIA_PIOGGIA_3H_MM:.0f} mm, ≥{PERSIST_HOURS}h) "
|
||||
f"<b>{html.escape(fascia)}</b> "
|
||||
f"(max 3h <b>{bo_alerts['rain3_max']:.1f} mm</b>)."
|
||||
)
|
||||
if bo_comp and bo_comp.get("rain"):
|
||||
comp = bo_comp["rain"]
|
||||
icon_r3 = bo_comp["icon_stats"]["rain3_max"]
|
||||
@@ -695,14 +760,17 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
|
||||
line = f"• <b>{html.escape(x['name'])}</b>: "
|
||||
parts: List[str] = []
|
||||
if x["snow_alert"]:
|
||||
parts.append(f"❄️ neve (≥{PERSIST_HOURS}h) da ~{html.escape(x['snow_run_time'] or '—')} (24h {x['snow_24h']:.1f} cm)")
|
||||
fascia = x.get("snow_fascia") or f"~{x.get('snow_run_time') or '—'}"
|
||||
parts.append(
|
||||
f"❄️ neve (≥{PERSIST_HOURS}h) {html.escape(fascia)} "
|
||||
f"(24h {x['snow_24h']:.1f} cm)"
|
||||
)
|
||||
if x["rain_alert"]:
|
||||
rain_dur = x.get("rain_duration_hours", 0.0)
|
||||
rain_tot = x.get("total_rain_accumulation_mm", 0.0)
|
||||
if rain_dur > 0:
|
||||
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '—')} (durata ~{rain_dur:.0f}h, totale ~{rain_tot:.1f}mm)")
|
||||
else:
|
||||
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '—')}")
|
||||
fascia = x.get("rain_fascia") or f"~{x.get('rain_persist_time') or '—'}"
|
||||
parts.append(
|
||||
f"🌧️ pioggia forte {html.escape(fascia)} "
|
||||
f"(max 3h {x['rain3_max']:.1f} mm)"
|
||||
)
|
||||
line += " | ".join(parts)
|
||||
msg.append(line)
|
||||
|
||||
@@ -725,15 +793,65 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
|
||||
msg.append("<i>Fonte dati: Open-Meteo</i>")
|
||||
|
||||
# FIX: usare \n invece di <br/>
|
||||
ok = telegram_send_html("\n".join(msg), chat_ids=chat_ids)
|
||||
html_msg = "\n".join(msg)
|
||||
plain = build_plain_summary(bo_alerts, route_alerts)
|
||||
ok = telegram_send_html(html_msg, chat_ids=chat_ids)
|
||||
if ok:
|
||||
LOGGER.info("Notifica inviata.")
|
||||
LOGGER.info("Notifica inviata su Telegram.")
|
||||
else:
|
||||
LOGGER.warning("Notifica NON inviata.")
|
||||
LOGGER.info("Telegram saltato/sospeso; consegna via WebApp.")
|
||||
|
||||
save_state(True, sig)
|
||||
detail = {
|
||||
"summary": plain,
|
||||
"window_hours": HOURS_AHEAD,
|
||||
"persist_hours": PERSIST_HOURS,
|
||||
"rain_threshold_mm_3h": SOGLIA_PIOGGIA_3H_MM,
|
||||
"first_event_time": first_event_time(bo_alerts, route_alerts),
|
||||
"bologna": {
|
||||
"snow_alert": bool(bo_alerts.get("snow_alert")),
|
||||
"rain_alert": bool(bo_alerts.get("rain_alert")),
|
||||
"snow_run_time": bo_alerts.get("snow_run_time"),
|
||||
"snow_run_end_time": bo_alerts.get("snow_run_end_time"),
|
||||
"snow_fascia": bo_alerts.get("snow_fascia"),
|
||||
"rain_persist_time": bo_alerts.get("rain_persist_time"),
|
||||
"rain_persist_end_time": bo_alerts.get("rain_persist_end_time"),
|
||||
"rain_fascia": bo_alerts.get("rain_fascia"),
|
||||
"snow_24h": bo_alerts.get("snow_24h"),
|
||||
"rain3_max": bo_alerts.get("rain3_max"),
|
||||
},
|
||||
"route_issues": [
|
||||
{
|
||||
"name": x.get("name"),
|
||||
"snow_alert": bool(x.get("snow_alert")),
|
||||
"rain_alert": bool(x.get("rain_alert")),
|
||||
"snow_run_time": x.get("snow_run_time"),
|
||||
"snow_run_end_time": x.get("snow_run_end_time"),
|
||||
"snow_fascia": x.get("snow_fascia"),
|
||||
"rain_persist_time": x.get("rain_persist_time"),
|
||||
"rain_persist_end_time": x.get("rain_persist_end_time"),
|
||||
"rain_fascia": x.get("rain_fascia"),
|
||||
"snow_24h": x.get("snow_24h"),
|
||||
"rain3_max": x.get("rain3_max"),
|
||||
"rain_persist_run_len": x.get("rain_persist_run_len"),
|
||||
}
|
||||
for x in route_alerts
|
||||
if x.get("snow_alert") or x.get("rain_alert")
|
||||
],
|
||||
}
|
||||
save_state(True, sig, detail)
|
||||
notify_webapp("Allerta percorso scuola", plain, severity="warning")
|
||||
else:
|
||||
LOGGER.info("Allerta già notificata e invariata.")
|
||||
# Aggiorna comunque summary nello state se manca (card WebApp)
|
||||
if not state.get("summary"):
|
||||
plain = build_plain_summary(bo_alerts, route_alerts)
|
||||
save_state(True, sig, {
|
||||
"summary": plain,
|
||||
"window_hours": HOURS_AHEAD,
|
||||
"persist_hours": PERSIST_HOURS,
|
||||
"rain_threshold_mm_3h": SOGLIA_PIOGGIA_3H_MM,
|
||||
"first_event_time": first_event_time(bo_alerts, route_alerts),
|
||||
})
|
||||
return
|
||||
|
||||
# --- Scenario B: Rientro ---
|
||||
@@ -747,12 +865,18 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
|
||||
f"di neve (≥{PERSIST_HOURS}h) o pioggia 3h sopra soglia (≥{PERSIST_HOURS}h).\n"
|
||||
"<i>Fonte dati: Open-Meteo</i>"
|
||||
)
|
||||
plain = (
|
||||
f"Allerta rientrata (Bologna / percorso scuola).\n"
|
||||
f"Nelle prossime {HOURS_AHEAD} ore non risultano più neve persistente "
|
||||
f"(≥{PERSIST_HOURS}h) né pioggia 3h sopra soglia (≥{PERSIST_HOURS}h)."
|
||||
)
|
||||
ok = telegram_send_html(msg, chat_ids=chat_ids)
|
||||
if ok:
|
||||
LOGGER.info("Rientro notificato.")
|
||||
LOGGER.info("Rientro notificato su Telegram.")
|
||||
else:
|
||||
LOGGER.warning("Rientro NON inviato.")
|
||||
LOGGER.info("Rientro Telegram saltato/sospeso; consegna via WebApp.")
|
||||
save_state(False, "")
|
||||
notify_webapp("Allerta percorso scuola rientrata", plain, severity="info")
|
||||
return
|
||||
|
||||
# --- Scenario C: Tranquillo ---
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regressione visibilità ore nella tabella meteo 48h."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from open_meteo_precip import (
|
||||
apply_hourly_daily_precip,
|
||||
hourly_table_should_show,
|
||||
overlay_icon_precip_on_hourly,
|
||||
)
|
||||
|
||||
|
||||
def test_first_24h_all_shown():
|
||||
for h in range(24):
|
||||
assert hourly_table_should_show(h, 0.0, 3) is True
|
||||
|
||||
|
||||
def test_second_day_even_hours_shown():
|
||||
assert hourly_table_should_show(24, 0.0, 3) is True # lun 08
|
||||
assert hourly_table_should_show(32, 5.0, 63) is True # lun 16
|
||||
|
||||
|
||||
def test_second_day_odd_dry_hidden():
|
||||
assert hourly_table_should_show(31, 0.0, 3) is False # lun 15 secco
|
||||
|
||||
|
||||
def test_second_day_odd_wet_shown():
|
||||
# Caso reale 17/08/2026: 15:00 = 17.4 mm (code 65) era nascosto → 48h mostrava solo 5 mm alle 16
|
||||
assert hourly_table_should_show(31, 17.4, 65) is True
|
||||
assert hourly_table_should_show(31, 0.0, 65) is True # codice precip anche con mm 0
|
||||
|
||||
|
||||
def test_apply_hourly_daily_precip_matches_hours():
|
||||
hourly = {
|
||||
"time": [f"2026-08-17T{h:02d}:00" for h in range(24)],
|
||||
"precipitation": [0.0] * 24,
|
||||
"rain": [0.0] * 24,
|
||||
"showers": [0.0] * 24,
|
||||
"snowfall": [0.0] * 24,
|
||||
}
|
||||
hourly["rain"][15] = 17.4
|
||||
hourly["rain"][16] = 5.0
|
||||
hourly["precipitation"][15] = 17.4
|
||||
hourly["precipitation"][16] = 5.0
|
||||
daily = {
|
||||
"time": ["2026-08-17"],
|
||||
"precipitation_sum": [18.0],
|
||||
"rain_sum": [18.0],
|
||||
"showers_sum": [20.0],
|
||||
"snowfall_sum": [0.0],
|
||||
}
|
||||
out = apply_hourly_daily_precip(daily, hourly)
|
||||
assert out["precipitation_sum"][0] == 22.4
|
||||
assert out["rain_sum"][0] == 22.4
|
||||
assert out["showers_sum"][0] == 0.0
|
||||
|
||||
|
||||
def test_overlay_matches_normalized_timestamps():
|
||||
target = {
|
||||
"time": ["2026-08-17T15:00"],
|
||||
"precipitation": [0.0],
|
||||
"rain": [0.0],
|
||||
"showers": [0.0],
|
||||
"snowfall": [0.0],
|
||||
}
|
||||
icon = {
|
||||
"time": ["2026-08-17T15:00:00"],
|
||||
"precipitation": [17.4],
|
||||
"rain": [17.4],
|
||||
"showers": [0.0],
|
||||
"snowfall": [0.0],
|
||||
}
|
||||
out = overlay_icon_precip_on_hourly(target, icon)
|
||||
assert abs(out["precipitation"][0] - 17.4) < 0.01
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_first_24h_all_shown()
|
||||
test_second_day_even_hours_shown()
|
||||
test_second_day_odd_dry_hidden()
|
||||
test_second_day_odd_wet_shown()
|
||||
test_apply_hourly_daily_precip_matches_hours()
|
||||
test_overlay_matches_normalized_timestamps()
|
||||
print("OK hourly_table_should_show")
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression checks for Meteo7 interpretation (weathercode mode, ice gates, hours, trend max/min)."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from previsione7 import (
|
||||
_merge_weathercode,
|
||||
_format_event_hours,
|
||||
analyze_daily_events,
|
||||
analyze_temperature_trend,
|
||||
format_detailed_trend_explanation,
|
||||
get_precip_type,
|
||||
)
|
||||
|
||||
|
||||
def test_weathercode_mode_not_median():
|
||||
# 61 (rain) + 71 (snow) → mediana numerica sarebbe 66 (FZRA); moda deve evitare 66
|
||||
assert _merge_weathercode([61, 71], preferred=61) == 61
|
||||
assert _merge_weathercode([61, 71], preferred=71) == 71
|
||||
assert _merge_weathercode([61, 61, 71]) == 61
|
||||
assert _merge_weathercode([66, 66, 61]) == 66
|
||||
print("OK weathercode mode")
|
||||
|
||||
|
||||
def test_get_precip_type_temp_gate():
|
||||
assert "Congelantesi" not in get_precip_type(67, temp=28.0)
|
||||
assert "Pioggia" in get_precip_type(67, temp=28.0)
|
||||
assert "Congelantesi" in get_precip_type(67, temp=0.5)
|
||||
assert "Neve" not in get_precip_type(71, temp=20.0)
|
||||
assert "Neve" in get_precip_type(71, temp=-1.0)
|
||||
print("OK precip type temp gate")
|
||||
|
||||
|
||||
def test_gelicidio_hot_air_no_event():
|
||||
times = [f"2026-07-21T{h:02d}:00" for h in range(24)]
|
||||
codes = [0] * 24
|
||||
codes[20] = 67 # FZRA spurio
|
||||
precip = [0.0] * 24
|
||||
precip[20] = 1.2
|
||||
temps = [28.0] * 24
|
||||
dewpoints = [18.0] * 24
|
||||
winds = [5.0] * 24
|
||||
events = analyze_daily_events(
|
||||
times, codes, None, precip, winds, temps, dewpoints,
|
||||
snowfalls=[0.0] * 24, rains=precip[:], soil_temps=[25.0] * 24,
|
||||
cloud_covers=[50.0] * 24, wind_speeds=winds,
|
||||
)
|
||||
ice_like = [e for e in events if "GELICIDIO" in e or "BRINA" in e or "Gelata" in e or "Black Ice" in e]
|
||||
assert not ice_like, f"unexpected ice events in heat: {ice_like}"
|
||||
rain_like = [e for e in events if "Pioggia" in e or "Congelantesi" in e]
|
||||
assert rain_like, "expected rain event"
|
||||
assert all("Congelantesi" not in e for e in rain_like)
|
||||
print("OK no gelicidio at 28°C")
|
||||
|
||||
|
||||
def test_single_hour_event_range():
|
||||
times = [f"2026-07-21T{h:02d}:00" for h in range(24)]
|
||||
assert _format_event_hours(times, 20, 20) == "20:00-21:00"
|
||||
assert _format_event_hours(times, 20, 21) == "20:00-22:00"
|
||||
# last hour of day
|
||||
assert _format_event_hours(times, 23, 23) == "23:00-00:00"
|
||||
|
||||
codes = [0] * 24
|
||||
precip = [0.0] * 24
|
||||
precip[20] = 2.0
|
||||
temps = [22.0] * 24
|
||||
events = analyze_daily_events(
|
||||
times, codes, None, precip, [5.0] * 24, temps, [12.0] * 24,
|
||||
snowfalls=[0.0] * 24, rains=precip[:],
|
||||
)
|
||||
rain_ev = [e for e in events if "🕒" in e]
|
||||
assert rain_ev, events
|
||||
assert "20:00-21:00" in rain_ev[0]
|
||||
assert "20:00-20:00" not in rain_ev[0]
|
||||
print("OK single-hour 20:00-21:00")
|
||||
|
||||
|
||||
def test_trend_max_min_not_media():
|
||||
# Heatwave: max stay high, slight drop → calo_termico not fronte_freddo
|
||||
tmax = [34, 35, 36, 33, 30, 29, 28, 27, 26, 25]
|
||||
tmin = [22, 23, 24, 21, 20, 19, 18, 17, 16, 15]
|
||||
trend = analyze_temperature_trend(tmax, tmin, days=10)
|
||||
assert trend is not None
|
||||
assert "first_max" in trend and "first_min" in trend
|
||||
assert trend["type"] == "calo_termico"
|
||||
text = format_detailed_trend_explanation(trend, daily_time_list=[f"2026-07-{21+i:02d}" for i in range(10)])
|
||||
assert "media" not in text.lower()
|
||||
assert "Massime:" in text and "Minime:" in text
|
||||
assert "Fronte Freddo" not in text
|
||||
print("OK trend max/min calo_termico")
|
||||
|
||||
|
||||
def test_real_gelicidio_cold():
|
||||
times = [f"2026-01-10T{h:02d}:00" for h in range(24)]
|
||||
codes = [0] * 24
|
||||
codes[8] = 67
|
||||
precip = [0.0] * 24
|
||||
precip[8] = 0.5
|
||||
temps = [5.0] * 24
|
||||
temps[8] = -0.5
|
||||
events = analyze_daily_events(
|
||||
times, codes, None, precip, [3.0] * 24, temps, [-1.0] * 24,
|
||||
snowfalls=[0.0] * 24, rains=precip[:], soil_temps=[-1.0] * 24,
|
||||
cloud_covers=[80.0] * 24, wind_speeds=[3.0] * 24,
|
||||
)
|
||||
assert any("GELICIDIO" in e for e in events), events
|
||||
print("OK real gelicidio at subzero")
|
||||
|
||||
|
||||
def test_monday_storm_event_mm_is_hourly_sum():
|
||||
"""15:00=17.4 + 16:00=5.0 → un evento 22.4 mm, non 5 mm né 22.4 ripetuto."""
|
||||
times = [f"2026-08-17T{h:02d}:00" for h in range(24)]
|
||||
precip = [0.0] * 24
|
||||
precip[15] = 17.4
|
||||
precip[16] = 5.0
|
||||
codes = [3] * 24
|
||||
codes[15] = 65
|
||||
codes[16] = 63
|
||||
temps = [26.0] * 24
|
||||
events = analyze_daily_events(
|
||||
times, codes, None, precip, [18.0] * 24, temps, [16.0] * 24,
|
||||
snowfalls=[0.0] * 24, rains=precip[:],
|
||||
)
|
||||
rain_ev = [e for e in events if "🕒" in e]
|
||||
assert len(rain_ev) == 1, rain_ev
|
||||
assert "15:00-17:00" in rain_ev[0]
|
||||
assert "22.4mm" in rain_ev[0]
|
||||
assert rain_ev[0].count("22.4mm") == 1
|
||||
print("OK lun 17 15-17h = 22.4mm")
|
||||
|
||||
|
||||
def test_three_pulses_keep_own_mm_not_daily_total():
|
||||
times = [f"2026-08-16T{h:02d}:00" for h in range(24)]
|
||||
precip = [0.0] * 24
|
||||
precip[10] = 18.0
|
||||
precip[14] = 19.0
|
||||
precip[18] = 20.0
|
||||
events = analyze_daily_events(
|
||||
times, [63] * 24, None, precip, [10.0] * 24, [24.0] * 24, [14.0] * 24,
|
||||
snowfalls=[0.0] * 24, rains=precip[:],
|
||||
)
|
||||
rain_ev = [e for e in events if "🕒" in e]
|
||||
assert len(rain_ev) == 3, rain_ev
|
||||
assert "18.0mm" in rain_ev[0]
|
||||
assert "19.0mm" in rain_ev[1]
|
||||
assert "20.0mm" in rain_ev[2]
|
||||
assert all("57.0mm" not in e for e in rain_ev)
|
||||
print("OK tre fasce con mm propri")
|
||||
|
||||
|
||||
def test_last_hour_rain_is_emitted():
|
||||
times = [f"2026-08-17T{h:02d}:00" for h in range(24)]
|
||||
precip = [0.0] * 24
|
||||
precip[23] = 4.2
|
||||
events = analyze_daily_events(
|
||||
times, [61] * 24, None, precip, [8.0] * 24, [20.0] * 24, [12.0] * 24,
|
||||
snowfalls=[0.0] * 24, rains=precip[:],
|
||||
)
|
||||
rain_ev = [e for e in events if "🕒" in e]
|
||||
assert rain_ev, events
|
||||
assert "23:00-00:00" in rain_ev[0]
|
||||
assert "4.2mm" in rain_ev[0]
|
||||
print("OK pioggia ultima ora del giorno")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_weathercode_mode_not_median()
|
||||
test_get_precip_type_temp_gate()
|
||||
test_gelicidio_hot_air_no_event()
|
||||
test_single_hour_event_range()
|
||||
test_trend_max_min_not_media()
|
||||
test_real_gelicidio_cold()
|
||||
test_monday_storm_event_mm_is_hourly_sum()
|
||||
test_three_pulses_keep_own_mm_not_daily_total()
|
||||
test_last_hour_rain_is_emitted()
|
||||
print("\nAll interpretation regression checks passed.")
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Helper: testo plain + summary state + mirror WebApp per allerte meteo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
LOGGER = logging.getLogger("webapp_alert")
|
||||
|
||||
|
||||
def message_to_plain(message: str, is_html: bool = False) -> str:
|
||||
text = message or ""
|
||||
if is_html:
|
||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
||||
text = re.sub(r"</p\s*>", "\n", text, flags=re.I)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = (
|
||||
text.replace("*", "")
|
||||
.replace("_", "")
|
||||
.replace("`", "")
|
||||
.replace(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
)
|
||||
lines = [ln.rstrip() for ln in text.splitlines()]
|
||||
# collassa righe vuote multiple
|
||||
out: list[str] = []
|
||||
blank = False
|
||||
for ln in lines:
|
||||
if not ln.strip():
|
||||
if not blank:
|
||||
out.append("")
|
||||
blank = True
|
||||
else:
|
||||
out.append(ln.strip())
|
||||
blank = False
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def remember_summary(state: Optional[Dict[str, Any]], plain: str, limit: int = 3000) -> str:
|
||||
summary = (plain or "").strip()[:limit]
|
||||
if state is not None and summary:
|
||||
state["summary"] = summary
|
||||
return summary
|
||||
|
||||
|
||||
def publish_web_alert(
|
||||
message: str,
|
||||
category: str,
|
||||
severity: str = "warning",
|
||||
*,
|
||||
is_html: bool = False,
|
||||
title: Optional[str] = None,
|
||||
state: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Salva summary nello state (se passato) e notifica la WebApp (solo canale web)."""
|
||||
plain = message_to_plain(message, is_html=is_html)
|
||||
if not plain:
|
||||
return False
|
||||
remember_summary(state, plain)
|
||||
head = (title or plain.split("\n", 1)[0]).strip()[:80] or category
|
||||
body = plain[:2500]
|
||||
try:
|
||||
shared = "/home/daniely/docker/shared"
|
||||
if shared not in sys.path:
|
||||
sys.path.insert(0, shared)
|
||||
from loogle_core.alert_dispatcher import send_web
|
||||
|
||||
ok = send_web(head, body, category=category, severity=severity)
|
||||
if not ok:
|
||||
LOGGER.debug("send_web returned False for %s", category)
|
||||
return bool(ok)
|
||||
except Exception as exc:
|
||||
LOGGER.warning("publish_web_alert failed (%s): %s", category, exc)
|
||||
return False
|
||||
Reference in new issue
Block a user