Compare commits
0
Commits
main
..
abbfa37293
No files matched your search
@@ -1,2 +0,0 @@
|
||||
sync_loogle_bot.log
|
||||
*.log
|
||||
@@ -1,39 +1,14 @@
|
||||
global_defs {
|
||||
enable_script_security
|
||||
script_user root
|
||||
}
|
||||
|
||||
vrrp_script chk_ha {
|
||||
script "/usr/local/sbin/keepalived-check-ha.sh"
|
||||
interval 5
|
||||
weight -30
|
||||
fall 3
|
||||
rise 2
|
||||
user root
|
||||
}
|
||||
|
||||
vrrp_instance VI_1 {
|
||||
state BACKUP
|
||||
state MASTER
|
||||
interface eth0
|
||||
virtual_router_id 51
|
||||
priority 101
|
||||
advert_int 2
|
||||
preempt_delay 30
|
||||
unicast_src_ip 192.168.128.80
|
||||
unicast_peer {
|
||||
192.168.128.81
|
||||
}
|
||||
priority 101 # 101 = Priorità più alta (Master)
|
||||
advert_int 1
|
||||
authentication {
|
||||
auth_type PASS
|
||||
auth_pass @Dedelove1
|
||||
}
|
||||
virtual_ipaddress {
|
||||
192.168.128.85
|
||||
192.168.128.85 # Il nostro VIP
|
||||
}
|
||||
track_script {
|
||||
chk_ha
|
||||
}
|
||||
notify_master "/usr/local/sbin/keepalived-notify-master.sh"
|
||||
notify_backup "/usr/local/sbin/keepalived-notify-backup.sh"
|
||||
notify_fault "/usr/local/sbin/keepalived-notify-fault.sh"
|
||||
}
|
||||
@@ -1,28 +1,9 @@
|
||||
global_defs {
|
||||
enable_script_security
|
||||
script_user root
|
||||
}
|
||||
|
||||
vrrp_script chk_ha {
|
||||
script "/usr/local/sbin/keepalived-check-ha.sh"
|
||||
interval 5
|
||||
weight -30
|
||||
fall 3
|
||||
rise 2
|
||||
user root
|
||||
}
|
||||
|
||||
vrrp_instance VI_1 {
|
||||
state BACKUP
|
||||
interface eth0
|
||||
virtual_router_id 51
|
||||
priority 100
|
||||
advert_int 2
|
||||
preempt_delay 30
|
||||
unicast_src_ip 192.168.128.81
|
||||
unicast_peer {
|
||||
192.168.128.80
|
||||
}
|
||||
priority 100 # 100 = Priorità più bassa (Backup)
|
||||
advert_int 1
|
||||
authentication {
|
||||
auth_type PASS
|
||||
auth_pass @Dedelove1
|
||||
@@ -30,10 +11,4 @@ vrrp_instance VI_1 {
|
||||
virtual_ipaddress {
|
||||
192.168.128.85
|
||||
}
|
||||
track_script {
|
||||
chk_ha
|
||||
}
|
||||
notify_master "/usr/local/sbin/keepalived-notify-master.sh"
|
||||
notify_backup "/usr/local/sbin/keepalived-notify-backup.sh"
|
||||
notify_fault "/usr/local/sbin/keepalived-notify-fault.sh"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
# Override opzionali per weekly-maintenance.sh (Pi-1 Master)
|
||||
# HOST_LABEL="Pi-1 (Master)"
|
||||
# DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
|
||||
# Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2)
|
||||
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
|
||||
# turni-app:live-latest
|
||||
# Dopo Watchtower: pull registry da rete/compose/failover anche senza container.
|
||||
# Override: FAILOVER_COMPOSE_DIR=...
|
||||
@@ -1,10 +0,0 @@
|
||||
# Override opzionali per weekly-maintenance.sh (Pi-2 Backup)
|
||||
# HOST_LABEL="Pi-2 (Backup)"
|
||||
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
|
||||
# CHECK_PIP3=true
|
||||
# DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
|
||||
# Cron consigliato: 40 0 * * 6 (tra irrigazione serale ~19:30 e notturna ~02:30)
|
||||
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
|
||||
# irrigazione, turni-app:beta/alpha, meteo-alert, loogle-casa, ewelink_smart_home
|
||||
# Dopo Watchtower: pull registry da rete/compose/failover (Paperless, VW, Stalwart, …)
|
||||
# anche senza container. Override: FAILOVER_COMPOSE_DIR=...
|
||||
@@ -1,4 +0,0 @@
|
||||
# Copia su Pi1: /home/daniely/.config/loogle-notify.env
|
||||
LOOGLE_CASA_URL=https://casa.loogle.it
|
||||
LOOGLE_NOTIFY_MODE=web
|
||||
LOOGLE_INTERNAL_TOKEN=
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/bash
|
||||
exec /home/daniely/rete/scripts/dnsexit-update.sh
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Verifica se è stato passato un nome utente
|
||||
if [ -z "$1" ]; then
|
||||
echo "Errore: Devi specificare un nome utente."
|
||||
echo "Uso: ./new-vpn-user.sh NomeDispositivo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
USER_NAME="$1"
|
||||
CONFIG_DIR="/home/daniely/configs"
|
||||
CONF_FILE="$CONFIG_DIR/$USER_NAME.conf"
|
||||
QR_FILE="$CONFIG_DIR/$USER_NAME.png"
|
||||
|
||||
echo "--- Creazione Utente WireGuard: $USER_NAME ---"
|
||||
|
||||
# 1. Crea l'utente con PiVPN (senza chiedere conferme extra)
|
||||
pivpn add -n "$USER_NAME"
|
||||
|
||||
# 2. Verifica se il file .conf è stato creato
|
||||
if [ -f "$CONF_FILE" ]; then
|
||||
echo "✅ Configurazione creata: $CONF_FILE"
|
||||
|
||||
# 3. Genera l'immagine QR Code dal file .conf
|
||||
# Legge il file conf e lo trasforma in un'immagine PNG
|
||||
qrencode -t png -o "$QR_FILE" -r "$CONF_FILE"
|
||||
echo "✅ QR Code generato: $QR_FILE"
|
||||
|
||||
echo ""
|
||||
echo "-------------------------------------------------------"
|
||||
echo "PER SCARICARE I FILE SUL TUO MAC:"
|
||||
echo "Apri un NUOVO terminale sul Mac (non SSH) e incolla questo:"
|
||||
echo ""
|
||||
# Genera il comando scp dinamico con l'IP corrente del Pi
|
||||
CURRENT_IP=$(hostname -I | awk '{print $1}')
|
||||
echo "scp $USER@$CURRENT_IP:$CONFIG_DIR/$USER_NAME.* ~/Desktop/"
|
||||
echo "-------------------------------------------------------"
|
||||
else
|
||||
echo "❌ Errore: Il file di configurazione non è stato creato."
|
||||
fi
|
||||
@@ -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
|
||||
|
||||
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}"
|
||||
# --- 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"
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] sync-npm: $*"; }
|
||||
|
||||
log "Avvio clonazione NPM (Master → Pi2 + DS920)..."
|
||||
echo "[$(date)] Avvio clonazione totale NPM (Master -> Backup)..."
|
||||
|
||||
# 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"
|
||||
|
||||
# --- 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"
|
||||
# 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."
|
||||
else
|
||||
log "ERRORE: trasferimento Pi-2 fallito"
|
||||
echo "[$(date)] ERRORE CRITICO: Trasferimento 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
|
||||
|
||||
sudo rm -f "$TEMP_FILE"
|
||||
log "Sincronizzazione NPM completata."
|
||||
# 5. Pulizia locale
|
||||
rm "$TEMP_FILE"
|
||||
@@ -1,3 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper legacy — redirige al modulo infra-monitor
|
||||
exec /home/daniely/rete/infra-monitor/pi2-watchdog.sh "$@"
|
||||
#!/bin/bash
|
||||
|
||||
# ================================================
|
||||
# SENTINELLA DI BACKUP (Gira su Pi-1 Master)
|
||||
# Controlla solo che il Pi-2 sia vivo.
|
||||
# ================================================
|
||||
|
||||
# --- CONFIGURAZIONE ---
|
||||
# 👇👇 INSERISCI I TUOI DATI VERI QUI 👇👇
|
||||
BOT_TOKEN="8155587974:AAF9OekvBpixtk8ZH6KoIc0L8edbhdXt7A4"
|
||||
CHAT_ID="64463169"
|
||||
# 👆👆 FINE MODIFICHE 👆👆
|
||||
|
||||
# Il bersaglio da controllare (Pi-2 Backup)
|
||||
TARGET_IP="192.168.128.81"
|
||||
TARGET_NAME="🍓 Pi-2 (Backup & Monitor)"
|
||||
|
||||
# File di stato specifico per questo controllo
|
||||
STATE_FILE="/tmp/pi2_watchdog.state"
|
||||
|
||||
# Funzione per inviare messaggi Telegram con timeout breve
|
||||
send_telegram() {
|
||||
MSG="$1"
|
||||
curl -s --max-time 5 -X POST "https://api.telegram.org/bot$BOT_TOKEN/sendMessage" -d chat_id="$CHAT_ID" -d text="$MSG" -d parse_mode="Markdown" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# Inizializza stato se non esiste
|
||||
if [ ! -f "$STATE_FILE" ]; then echo "UP" > "$STATE_FILE"; fi
|
||||
LAST_STATE=$(cat "$STATE_FILE")
|
||||
|
||||
# --- IL PING ---
|
||||
# 3 tentativi, 2 secondi di timeout l'uno.
|
||||
# Basta che uno risponda per considerare il bersaglio UP.
|
||||
if ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1; then
|
||||
# === È ONLINE ===
|
||||
if [ "$LAST_STATE" == "DOWN" ]; then
|
||||
send_telegram "✅ **RISOLTO: $TARGET_NAME è tornato ONLINE!**%0AIl monitoraggio di rete è di nuovo attivo."
|
||||
echo "UP" > "$STATE_FILE"
|
||||
fi
|
||||
else
|
||||
# === È OFFLINE ===
|
||||
if [ "$LAST_STATE" == "UP" ]; then
|
||||
# Usiamo un'icona diversa per distinguerlo subito
|
||||
send_telegram "🛡️ **ALLARME SICUREZZA: $TARGET_NAME è OFFLINE!**%0A%0AAttenzione: Il sistema di monitoraggio principale (Super Watchdog) non sta funzionando."
|
||||
echo "DOWN" > "$STATE_FILE"
|
||||
fi
|
||||
fi
|
||||
@@ -1,597 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================
|
||||
# Manutenzione settimanale Raspberry Pi
|
||||
# Aggiorna OS, Pi-hole, EEPROM; verifica Docker/pip; report + Telegram.
|
||||
# Conclude sempre con reboot host (salvo --no-reboot o REBOOT_ON_SUCCESS=false).
|
||||
# Eseguire come root (cron root).
|
||||
# ==============================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# PATH completo: il crontab root non eredita /usr/sbin (serve per shutdown, ecc.)
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
CONF_FILE="/etc/weekly-maintenance.conf"
|
||||
LOG_FILE="/var/log/weekly-maintenance.log"
|
||||
REPORT_FILE="/var/log/weekly-maintenance-report.txt"
|
||||
TOKEN_FILE="/etc/telegram_dpc_bot_token"
|
||||
CHAT_ID="64463169"
|
||||
PIHOLE_BIN="/usr/local/bin/pihole"
|
||||
REBOOT_DELAY_MIN=2
|
||||
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
|
||||
WATCHTOWER_TIMEOUT=900
|
||||
# Compose HA usati al pivot: Watchtower aggiorna solo i container esistenti.
|
||||
FAILOVER_COMPOSE_DIR="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
|
||||
|
||||
HOST_LABEL="$(hostname -s)"
|
||||
REBOOT_ON_SUCCESS=true
|
||||
TELEGRAM_ENABLED=true
|
||||
CHECK_PIP3=false
|
||||
DOCKER_IGNORE_IMAGES=()
|
||||
DOCKER_PINNED_IMAGES=()
|
||||
|
||||
declare -a ERRORS=()
|
||||
declare -a WARNINGS=()
|
||||
declare -a NOTES=()
|
||||
REBOOT_RECOMMENDED=false
|
||||
APT_CHANGED=false
|
||||
DOCKER_UPDATED=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-reboot) REBOOT_ON_SUCCESS=false ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Configurazione host (override via /etc/weekly-maintenance.conf) ---
|
||||
case "$(hostname -s)" in
|
||||
pi1)
|
||||
HOST_LABEL="Pi-1 (Master)"
|
||||
# Immagini build locali (no registry pubblico) → escluse da Watchtower
|
||||
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||
;;
|
||||
pi2)
|
||||
HOST_LABEL="Pi-2 (Backup)"
|
||||
CHECK_PIP3=true
|
||||
# Immagini build locali (no registry pubblico) → escluse da Watchtower
|
||||
DOCKER_IGNORE_IMAGES=(
|
||||
"irrigazione:latest"
|
||||
"turni-app:beta-latest"
|
||||
"turni-app:alpha-latest"
|
||||
"meteo-alert:latest"
|
||||
"loogle-casa:latest"
|
||||
"ewelink_smart_home:1.4.6"
|
||||
)
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -f "$CONF_FILE" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$CONF_FILE"
|
||||
fi
|
||||
|
||||
# --- Utility ---
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
append_report() {
|
||||
printf '%s\n' "$*" >> "$REPORT_FILE"
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local title="$1"
|
||||
shift
|
||||
local output
|
||||
local rc=0
|
||||
|
||||
log "▶ $title"
|
||||
append_report ""
|
||||
append_report "=== $title ==="
|
||||
|
||||
output="$("$@" 2>&1)" || rc=$?
|
||||
if [[ -n "$output" ]]; then
|
||||
append_report "$output"
|
||||
log "$output"
|
||||
fi
|
||||
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
NOTES+=("OK: $title")
|
||||
log "✓ $title completato"
|
||||
else
|
||||
ERRORS+=("$title (exit $rc)")
|
||||
log "✗ $title fallito (exit $rc)"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
load_telegram_token() {
|
||||
if [[ ! -f "$TOKEN_FILE" ]]; then
|
||||
WARNINGS+=("Token Telegram assente ($TOKEN_FILE): notifiche disabilitate")
|
||||
TELEGRAM_ENABLED=false
|
||||
return 1
|
||||
fi
|
||||
BOT_TOKEN=$(tr -d '\n\r' < "$TOKEN_FILE")
|
||||
if [[ -z "$BOT_TOKEN" ]]; then
|
||||
WARNINGS+=("Token Telegram vuoto: notifiche disabilitate")
|
||||
TELEGRAM_ENABLED=false
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
send_telegram() {
|
||||
local msg="$1"
|
||||
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
|
||||
curl -s --max-time 15 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
||||
-d "chat_id=${CHAT_ID}" \
|
||||
--data-urlencode "text=${msg}" \
|
||||
-d "parse_mode=Markdown" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
send_telegram_document() {
|
||||
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
|
||||
[[ -f "$REPORT_FILE" ]] || return 0
|
||||
curl -s --max-time 30 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \
|
||||
-F "chat_id=${CHAT_ID}" \
|
||||
-F "document=@${REPORT_FILE}" \
|
||||
-F "caption=Report completo ${HOST_LABEL}" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
require_root() {
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "Eseguire come root (es. cron root)." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
image_is_ignored() {
|
||||
local image="$1"
|
||||
local ignored
|
||||
for ignored in "${DOCKER_IGNORE_IMAGES[@]}"; do
|
||||
[[ "$image" == "$ignored" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Elenco nomi container da escludere (immagini locali / no registry).
|
||||
# Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
|
||||
watchtower_disabled_containers() {
|
||||
local name image disabled=()
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
name=${line%%|*}
|
||||
image=${line#*|}
|
||||
if image_is_ignored "$image"; then
|
||||
disabled+=("$name")
|
||||
fi
|
||||
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
if ((${#disabled[@]} > 0)); then
|
||||
local IFS=,
|
||||
printf '%s' "${disabled[*]}"
|
||||
fi
|
||||
}
|
||||
|
||||
run_watchtower() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
|
||||
local disable_list
|
||||
disable_list=$(watchtower_disabled_containers)
|
||||
|
||||
log "▶ Watchtower (run-once, container aggiornabili da registry)"
|
||||
append_report ""
|
||||
append_report "=== Watchtower run-once ==="
|
||||
if [[ -n "$disable_list" ]]; then
|
||||
log "Watchtower: esclusi container locali: $disable_list"
|
||||
append_report "Esclusi (immagini locali): $disable_list"
|
||||
fi
|
||||
|
||||
local -a env_args=(
|
||||
-e WATCHTOWER_CLEANUP=true
|
||||
-e WATCHTOWER_ROLLING_RESTART=false
|
||||
-e "WATCHTOWER_TIMEOUT=${WATCHTOWER_TIMEOUT}"
|
||||
-e TZ=Europe/Rome
|
||||
)
|
||||
if [[ -n "$disable_list" ]]; then
|
||||
env_args+=(-e "WATCHTOWER_DISABLE_CONTAINERS=${disable_list}")
|
||||
fi
|
||||
|
||||
local output rc=0
|
||||
output=$(docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
"${env_args[@]}" \
|
||||
"$WATCHTOWER_IMAGE" \
|
||||
--run-once 2>&1) || rc=$?
|
||||
|
||||
if [[ -n "$output" ]]; then
|
||||
append_report "$output"
|
||||
printf '%s\n' "$output" | tail -30 | while IFS= read -r line; do log "$line"; done
|
||||
fi
|
||||
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
NOTES+=("Watchtower run-once OK")
|
||||
if grep -q "updated=[1-9]" <<< "$output"; then
|
||||
DOCKER_UPDATED=true
|
||||
REBOOT_RECOMMENDED=true
|
||||
NOTES+=("Watchtower: container aggiornati")
|
||||
fi
|
||||
else
|
||||
ERRORS+=("Watchtower run-once (exit $rc)")
|
||||
fi
|
||||
}
|
||||
|
||||
# Espande ${VAR:-default} (usato nei compose Turni). Altri ${VAR} → scarta.
|
||||
expand_compose_image() {
|
||||
local raw="$1"
|
||||
while [[ "$raw" =~ \$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\} ]]; do
|
||||
raw="${raw/${BASH_REMATCH[0]}/${BASH_REMATCH[2]}}"
|
||||
done
|
||||
[[ "$raw" == *'$'* ]] && return 1
|
||||
printf '%s' "$raw"
|
||||
}
|
||||
|
||||
# Build locali / no registry: non fare docker pull (fallirebbe o scaricherebbe un omonimo Hub).
|
||||
failover_image_is_local_build() {
|
||||
local image="$1"
|
||||
local name="${image%%:*}"
|
||||
image_is_ignored "$image" && return 0
|
||||
case "$name" in
|
||||
nodus-backend|nodus-frontend|loogle-casa|loogle-mcp|irrigazione|ewelink_smart_home|turni-app)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
collect_failover_registry_images() {
|
||||
local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
|
||||
local f line img expanded
|
||||
[[ -d "$dir" ]] || return 1
|
||||
while IFS= read -r f; do
|
||||
[[ -f "$f" ]] || continue
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
img=$(expand_compose_image "$line") || continue
|
||||
failover_image_is_local_build "$img" && continue
|
||||
printf '%s\n' "$img"
|
||||
done < <(sed -n 's/^[[:space:]]*image:[[:space:]]*//p' "$f" | sed 's/["'\'']//g')
|
||||
done < <(find "$dir" -type f \( -name '*.yml' -o -name '*.yaml' \) | sort)
|
||||
}
|
||||
|
||||
pull_failover_standby_images() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
|
||||
local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
|
||||
log "▶ Pull immagini failover (registry, anche senza container)"
|
||||
append_report ""
|
||||
append_report "=== Pull immagini failover (standby) ==="
|
||||
append_report "Compose: $dir"
|
||||
|
||||
if [[ ! -d "$dir" ]]; then
|
||||
WARNINGS+=("Directory compose failover assente: $dir")
|
||||
append_report "Directory assente"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local -a images=()
|
||||
local img
|
||||
while IFS= read -r img; do
|
||||
[[ -z "$img" ]] && continue
|
||||
images+=("$img")
|
||||
done < <(collect_failover_registry_images | sort -u)
|
||||
|
||||
if ((${#images[@]} == 0)); then
|
||||
WARNINGS+=("Nessuna immagine registry nei compose failover")
|
||||
append_report "Lista vuota"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pulled=0 failed=0 skipped=0
|
||||
local before after
|
||||
for img in "${images[@]}"; do
|
||||
before=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true)
|
||||
if docker pull "$img" >> "$REPORT_FILE" 2>&1; then
|
||||
after=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true)
|
||||
if [[ -n "$before" && "$before" == "$after" ]]; then
|
||||
skipped=$((skipped + 1))
|
||||
log " = $img (già aggiornata)"
|
||||
append_report "unchanged: $img"
|
||||
else
|
||||
pulled=$((pulled + 1))
|
||||
log " ↑ $img"
|
||||
append_report "updated: $img"
|
||||
fi
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
WARNINGS+=("docker pull fallito: $img")
|
||||
log " ✗ $img"
|
||||
fi
|
||||
done
|
||||
|
||||
NOTES+=("Failover images: ${#images[@]} registry, $pulled aggiornate, $skipped già ok, $failed errori")
|
||||
append_report "Riepilogo: ${#images[@]} immagini, aggiornate=$pulled, invariate=$skipped, errori=$failed"
|
||||
log "✓ Pull failover: aggiornate=$pulled invariate=$skipped errori=$failed"
|
||||
}
|
||||
|
||||
audit_apt() {
|
||||
local holds
|
||||
holds=$(apt-mark showhold 2>/dev/null || true)
|
||||
if [[ -n "$holds" ]]; then
|
||||
WARNINGS+=("Pacchetti apt bloccati (hold): ${holds//$'\n'/, }")
|
||||
append_report "$holds"
|
||||
fi
|
||||
|
||||
local upgradable
|
||||
upgradable=$(apt list --upgradable 2>/dev/null | tail -n +2 || true)
|
||||
if [[ -n "$upgradable" ]]; then
|
||||
WARNINGS+=("Restano pacchetti non aggiornati dopo full-upgrade")
|
||||
append_report "$upgradable"
|
||||
fi
|
||||
}
|
||||
|
||||
audit_pihole_versions() {
|
||||
local out
|
||||
out=$("$PIHOLE_BIN" -v 2>&1 || true)
|
||||
append_report "$out"
|
||||
if grep -qiE 'version is .*\(Latest: .*\)' <<< "$out"; then
|
||||
while IFS= read -r line; do
|
||||
local current latest
|
||||
current=$(sed -n 's/.*Version is \([^ ]*\).*/\1/p' <<< "$line")
|
||||
latest=$(sed -n 's/.*Latest: \([^)]*\).*/\1/p' <<< "$line")
|
||||
if [[ -n "$current" && -n "$latest" && "$current" != "$latest" && "$current" != "N/A" ]]; then
|
||||
WARNINGS+=("Pi-hole non allineato: $line")
|
||||
fi
|
||||
done <<< "$out"
|
||||
fi
|
||||
}
|
||||
|
||||
audit_eeprom() {
|
||||
command -v rpi-eeprom-update >/dev/null 2>&1 || return 0
|
||||
local out
|
||||
out=$(rpi-eeprom-update 2>&1 || true)
|
||||
append_report "$out"
|
||||
if grep -qiE 'UPDATE REQUIRED|update available|NEW EEPROM' <<< "$out"; then
|
||||
log "EEPROM: aggiornamento disponibile, applico..."
|
||||
append_report ""
|
||||
append_report "=== rpi-eeprom-update -a ==="
|
||||
if rpi-eeprom-update -a >> "$REPORT_FILE" 2>&1; then
|
||||
NOTES+=("EEPROM aggiornato")
|
||||
REBOOT_RECOMMENDED=true
|
||||
else
|
||||
ERRORS+=("rpi-eeprom-update -a")
|
||||
fi
|
||||
else
|
||||
NOTES+=("EEPROM: aggiornato")
|
||||
fi
|
||||
}
|
||||
|
||||
audit_docker() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
|
||||
append_report ""
|
||||
append_report "=== Docker audit ==="
|
||||
|
||||
local exited
|
||||
exited=$(docker ps -a --filter "status=exited" --format '{{.Names}} ({{.Image}})' 2>/dev/null || true)
|
||||
if [[ -n "$exited" ]]; then
|
||||
WARNINGS+=("Container Docker fermati (Exited): ${exited//$'\n'/, }")
|
||||
append_report "Exited:"
|
||||
append_report "$exited"
|
||||
fi
|
||||
|
||||
local name image
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
name=${line%%|*}
|
||||
image=${line#*|}
|
||||
|
||||
image_is_ignored "$image" && continue
|
||||
|
||||
if [[ "$image" =~ ^[0-9a-f]{12}$ ]]; then
|
||||
WARNINGS+=("Container '$name' usa immagine per ID ($image): preferire un tag versionato")
|
||||
fi
|
||||
|
||||
local pinned
|
||||
for pinned in "${DOCKER_PINNED_IMAGES[@]}"; do
|
||||
if [[ "$image" == "$pinned" ]]; then
|
||||
WARNINGS+=("Container '$name' usa tag bloccato ($pinned): Watchtower non passerà a :latest")
|
||||
fi
|
||||
done
|
||||
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
}
|
||||
|
||||
audit_pip3() {
|
||||
[[ "$CHECK_PIP3" == true ]] || return 0
|
||||
command -v pip3 >/dev/null 2>&1 || return 0
|
||||
|
||||
append_report ""
|
||||
append_report "=== pip3 outdated (sistema) ==="
|
||||
local outdated count
|
||||
outdated=$(pip3 list --outdated --format=columns 2>/dev/null | tail -n +3 || true)
|
||||
if [[ -n "$outdated" ]]; then
|
||||
count=$(wc -l <<< "$outdated")
|
||||
append_report "$outdated"
|
||||
WARNINGS+=("pip3: $count pacchetti Python di sistema non aggiornati (aggiornamento manuale/consigliato in venv)")
|
||||
else
|
||||
NOTES+=("pip3: tutti aggiornati")
|
||||
fi
|
||||
}
|
||||
|
||||
build_telegram_summary() {
|
||||
local icon status
|
||||
if ((${#ERRORS[@]} > 0)); then
|
||||
icon="🚨"
|
||||
status="ERRORI"
|
||||
elif ((${#WARNINGS[@]} > 0)); then
|
||||
icon="⚠️"
|
||||
status="WARNINGS"
|
||||
else
|
||||
icon="✅"
|
||||
status="OK"
|
||||
fi
|
||||
|
||||
local msg="${icon} *Manutenzione settimanale ${HOST_LABEL}*
|
||||
Stato: *${status}*
|
||||
Data: $(date '+%d/%m/%Y %H:%M')"
|
||||
|
||||
if [[ "$APT_CHANGED" == true ]]; then
|
||||
msg+="
|
||||
📦 apt: pacchetti aggiornati"
|
||||
fi
|
||||
if [[ "$DOCKER_UPDATED" == true ]]; then
|
||||
msg+="
|
||||
🐳 Docker: container aggiornati"
|
||||
fi
|
||||
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
|
||||
msg+="
|
||||
🔄 Reboot host programmato tra ${REBOOT_DELAY_MIN} min (fine manutenzione)"
|
||||
fi
|
||||
|
||||
if ((${#ERRORS[@]} > 0)); then
|
||||
msg+="
|
||||
*Errori (${#ERRORS[@]}):*"
|
||||
local e
|
||||
for e in "${ERRORS[@]}"; do
|
||||
msg+="
|
||||
• ${e}"
|
||||
done
|
||||
fi
|
||||
|
||||
if ((${#WARNINGS[@]} > 0)); then
|
||||
msg+="
|
||||
*Avvisi (${#WARNINGS[@]}):*"
|
||||
local w
|
||||
local n=0
|
||||
for w in "${WARNINGS[@]}"; do
|
||||
((n++)) || true
|
||||
[[ $n -le 8 ]] && msg+="
|
||||
• ${w}"
|
||||
done
|
||||
if ((${#WARNINGS[@]} > 8)); then
|
||||
msg+="
|
||||
• … +$((${#WARNINGS[@]} - 8)) avvisi (vedi report)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ((${#ERRORS[@]} == 0 && ${#WARNINGS[@]} == 0)); then
|
||||
msg+="
|
||||
Tutto aggiornato. Log: \`${LOG_FILE}\`"
|
||||
else
|
||||
msg+="
|
||||
Report completo allegato o in \`${REPORT_FILE}\`"
|
||||
fi
|
||||
|
||||
send_telegram "$msg"
|
||||
if ((${#ERRORS[@]} > 0 || ${#WARNINGS[@]} > 0)); then
|
||||
send_telegram_document
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
require_root
|
||||
|
||||
: > "$REPORT_FILE"
|
||||
log "========== Inizio manutenzione settimanale ($HOST_LABEL) =========="
|
||||
append_report "Manutenzione settimanale - $HOST_LABEL"
|
||||
append_report "Avviata: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
append_report "Host: $(hostname -f) ($(hostname -I | awk '{print $1}'))"
|
||||
|
||||
load_telegram_token || true
|
||||
|
||||
# 1. Aggiornamento pacchetti sistema
|
||||
if apt update >> "$REPORT_FILE" 2>&1; then
|
||||
NOTES+=("apt update OK")
|
||||
log "✓ apt update"
|
||||
else
|
||||
ERRORS+=("apt update")
|
||||
log "✗ apt update fallito"
|
||||
fi
|
||||
|
||||
BEFORE_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
|
||||
if apt full-upgrade -y >> "$REPORT_FILE" 2>&1; then
|
||||
NOTES+=("apt full-upgrade OK")
|
||||
log "✓ apt full-upgrade"
|
||||
AFTER_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
|
||||
if [[ "$BEFORE_UPGRADES" -gt "$AFTER_UPGRADES" ]] || [[ "$BEFORE_UPGRADES" -gt 0 ]]; then
|
||||
APT_CHANGED=true
|
||||
REBOOT_RECOMMENDED=true
|
||||
fi
|
||||
else
|
||||
ERRORS+=("apt full-upgrade")
|
||||
log "✗ apt full-upgrade fallito"
|
||||
fi
|
||||
|
||||
run_step "apt autoremove" apt autoremove -y
|
||||
audit_apt
|
||||
|
||||
# 2. Pi-hole
|
||||
if [[ -x "$PIHOLE_BIN" ]]; then
|
||||
run_step "pihole -up" "$PIHOLE_BIN" -up
|
||||
audit_pihole_versions
|
||||
else
|
||||
ERRORS+=("pihole non trovato in $PIHOLE_BIN")
|
||||
fi
|
||||
|
||||
# 3. EEPROM firmware
|
||||
audit_eeprom
|
||||
|
||||
# 4. Aggiornamento container Docker (Watchtower run-once; il daemon è MONITOR_ONLY)
|
||||
run_watchtower
|
||||
# 4b. Immagini registry dei compose failover (Paperless, Vaultwarden, …) anche se non c'è container
|
||||
pull_failover_standby_images
|
||||
|
||||
# 5. Audit residui
|
||||
audit_docker
|
||||
audit_pip3
|
||||
|
||||
append_report ""
|
||||
append_report "=== Riepilogo ==="
|
||||
append_report "Errori: ${#ERRORS[@]}"
|
||||
append_report "Avvisi: ${#WARNINGS[@]}"
|
||||
if ((${#ERRORS[@]} > 0)); then
|
||||
append_report "Dettaglio errori:"
|
||||
printf '%s\n' "${ERRORS[@]}" >> "$REPORT_FILE"
|
||||
fi
|
||||
if ((${#WARNINGS[@]} > 0)); then
|
||||
append_report "Dettaglio avvisi:"
|
||||
printf '%s\n' "${WARNINGS[@]}" >> "$REPORT_FILE"
|
||||
fi
|
||||
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
|
||||
# 5b. Il peer deve poter coprire: niente reboot se è giù o in manutenzione
|
||||
# (31/10: Pi-1 riavviato alle 04:50 con Pi-2 già giù → rete senza nessun nodo).
|
||||
REBOOT_GUARD="/home/daniely/rete/scripts/maintenance-reboot-guard.sh"
|
||||
if [[ "$REBOOT_ON_SUCCESS" == true && -x "$REBOOT_GUARD" ]]; then
|
||||
if guard_msg="$("$REBOOT_GUARD" check 2>&1)"; then
|
||||
log "Guardia reboot: ${guard_msg}"
|
||||
else
|
||||
REBOOT_ON_SUCCESS=false
|
||||
WARNINGS+=("Reboot saltato: ${guard_msg}")
|
||||
log "⚠ Reboot saltato: ${guard_msg}"
|
||||
fi
|
||||
fi
|
||||
|
||||
build_telegram_summary
|
||||
|
||||
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
|
||||
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
|
||||
log "Reboot host programmato tra ${REBOOT_DELAY_MIN} minuti (fine manutenzione settimanale)"
|
||||
append_report ""
|
||||
append_report "=== Reboot ==="
|
||||
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
|
||||
# Annuncio al peer: prende il VIP ma non pivota le app, e se il nodo non
|
||||
# torna entro il TTL il suo watchdog fa il failover vero.
|
||||
[[ -x "$REBOOT_GUARD" ]] && "$REBOOT_GUARD" announce "$REBOOT_DELAY_MIN" || true
|
||||
/usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
|
||||
ERRORS+=("shutdown -r (reboot programmato)")
|
||||
log "✗ Impossibile programmare il reboot"
|
||||
}
|
||||
else
|
||||
log "Reboot disabilitato (--no-reboot o REBOOT_ON_SUCCESS=false)"
|
||||
append_report ""
|
||||
append_report "=== Reboot ==="
|
||||
append_report "Saltato (disabilitato)"
|
||||
fi
|
||||
|
||||
log "========== Fine manutenzione settimanale =========="
|
||||
exit $(( ${#ERRORS[@]} > 0 ? 1 : 0 ))
|
||||
Binary file not shown.
@@ -1,115 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==============================================================================
|
||||
# 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
|
||||
|
||||
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" || fail "Impossibile entrare in $REPO_DIR"
|
||||
else
|
||||
fail "La cartella $REPO_DIR non esiste."
|
||||
fi
|
||||
|
||||
# Aggiorna il repository locale (pull) per evitare conflitti
|
||||
echo "🔄 Eseguo Git Pull..."
|
||||
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
|
||||
mkdir -p ./services/telegram-bot
|
||||
mkdir -p ./scripts/pi1-master
|
||||
mkdir -p ./configs
|
||||
|
||||
# 2. RACCOLTA FILE DAL PI-2 (LOCALE)
|
||||
# ----------------------------------
|
||||
echo "📂 Raccolta dinamica file locali (Pi-2)..."
|
||||
|
||||
# A. Script nella Home (solo .sh) -> scripts/pi2-backup
|
||||
rsync -av --include='*.sh' --exclude='*' /home/daniely/ "$REPO_DIR/scripts/pi2-backup/"
|
||||
|
||||
# B. Script del Bot (tutti .py e .sh) -> services/telegram-bot
|
||||
rsync -av --include='*.py' --include='*.sh' --exclude='*' /home/daniely/docker/telegram-bot/ "$REPO_DIR/services/telegram-bot/"
|
||||
|
||||
# C. File di Configurazione specifici (Keepalived)
|
||||
if [ -f "/etc/keepalived/keepalived.conf" ]; then
|
||||
sudo cp /etc/keepalived/keepalived.conf ./configs/keepalived_pi2.conf
|
||||
sudo chown daniely:daniely ./configs/keepalived_pi2.conf
|
||||
echo "✅ Configurazione Keepalived Pi2 copiata."
|
||||
fi
|
||||
|
||||
# 3. RACCOLTA FILE DAL PI-1 (REMOTO)
|
||||
# ----------------------------------
|
||||
echo "📡 Raccolta dinamica file remoti (Pi-1)..."
|
||||
|
||||
# A. Script nella Home remota (.sh e .py) -> 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)
|
||||
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
|
||||
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 e ogni sua copia (.env.bak*, .env.old...) restano esclusi: contengono JWT secret,
|
||||
# token Paperless/Gitea/HA e password. Passa solo .env.example (l'include va prima).
|
||||
mkdir -p "$REPO_DIR/services/loogle-mcp"
|
||||
rsync -av --delete -e "ssh -q" \
|
||||
--include='.env.example' --exclude='.env*' --exclude='data/' --exclude='__pycache__/' --exclude='*.pyc' --exclude='.cursor/' \
|
||||
"$PI1_USER@$PI1_IP:/home/daniely/docker/loogle-mcp/" "$REPO_DIR/services/loogle-mcp/" \
|
||||
|| echo "⚠️ rsync sorgente MCP fallito (ignorato)"
|
||||
|
||||
# 4. GIT PUSH
|
||||
# -----------
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "📝 Rilevati cambiamenti. Eseguo Commit e Push..."
|
||||
|
||||
git add .
|
||||
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
|
||||
|
||||
echo "=== Fine Backup Git ==="
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/bash
|
||||
exec /home/daniely/rete/scripts/dnsexit-update.sh
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
exec /home/daniely/rete/infra-monitor/perimeter-watch.sh "$@"
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ================================================
|
||||
# SENTINELLA DI BACKUP (Gira su Pi-1 Master)
|
||||
# Controlla Pi-2 e attiva failover bundle IoT su Pi-1 se down.
|
||||
# ================================================
|
||||
|
||||
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
||||
HA_FAILOVER="/home/daniely/rete/scripts/ha-failover.sh"
|
||||
|
||||
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||
echo "Errore: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_IP="192.168.128.81"
|
||||
TARGET_NAME="🍓 Pi-2 (Backup & Monitor)"
|
||||
STATE_FILE="/mnt/ha-apps/.metadata/pi2-watchdog.state"
|
||||
SIM_STATE="/mnt/ha-apps/.metadata/failover-sim.state"
|
||||
LEGACY_STATE_FILE="/tmp/pi2_watchdog.state"
|
||||
SSH_OPTS=(-o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
|
||||
|
||||
if [[ -f "$SIM_STATE" ]] && [[ "$(cat "$SIM_STATE" 2>/dev/null)" != "none" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
send_alert() {
|
||||
local title="$1"
|
||||
local body="$2"
|
||||
local severity="$3"
|
||||
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category watchdog_pi2 \
|
||||
--severity "$severity" &
|
||||
}
|
||||
|
||||
pi2_reachable() {
|
||||
ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1 && return 0
|
||||
ssh "${SSH_OPTS[@]}" pi2 "exit 0" 2>/dev/null
|
||||
}
|
||||
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
if [ -f "$LEGACY_STATE_FILE" ]; then
|
||||
cp "$LEGACY_STATE_FILE" "$STATE_FILE"
|
||||
else
|
||||
echo "UP" > "$STATE_FILE"
|
||||
fi
|
||||
fi
|
||||
LAST_STATE=$(cat "$STATE_FILE")
|
||||
|
||||
if pi2_reachable; then
|
||||
if [ "$LAST_STATE" == "DOWN" ]; then
|
||||
send_alert \
|
||||
"Pi-2 online" \
|
||||
"RISOLTO: $TARGET_NAME è tornato ONLINE! Failback bundle IoT in corso." \
|
||||
"info"
|
||||
echo "UP" > "$STATE_FILE"
|
||||
if [[ -x "$HA_FAILOVER" ]]; then
|
||||
sudo bash -c "\"$HA_FAILOVER\" stop-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if [ "$LAST_STATE" == "UP" ]; then
|
||||
send_alert \
|
||||
"Pi-2 offline" \
|
||||
"ALLARME: $TARGET_NAME è OFFLINE! Avvio failover bundle IoT su Pi-1." \
|
||||
"warning"
|
||||
echo "DOWN" > "$STATE_FILE"
|
||||
if [[ -x "$HA_FAILOVER" ]]; then
|
||||
sudo bash -c "\"$HA_FAILOVER\" start-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -1,597 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================
|
||||
# Manutenzione settimanale Raspberry Pi
|
||||
# Aggiorna OS, Pi-hole, EEPROM; verifica Docker/pip; report + Telegram.
|
||||
# Conclude sempre con reboot host (salvo --no-reboot o REBOOT_ON_SUCCESS=false).
|
||||
# Eseguire come root (cron root).
|
||||
# ==============================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# PATH completo: il crontab root non eredita /usr/sbin (serve per shutdown, ecc.)
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
CONF_FILE="/etc/weekly-maintenance.conf"
|
||||
LOG_FILE="/var/log/weekly-maintenance.log"
|
||||
REPORT_FILE="/var/log/weekly-maintenance-report.txt"
|
||||
TOKEN_FILE="/etc/telegram_dpc_bot_token"
|
||||
CHAT_ID="64463169"
|
||||
PIHOLE_BIN="/usr/local/bin/pihole"
|
||||
REBOOT_DELAY_MIN=2
|
||||
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
|
||||
WATCHTOWER_TIMEOUT=900
|
||||
# Compose HA usati al pivot: Watchtower aggiorna solo i container esistenti.
|
||||
FAILOVER_COMPOSE_DIR="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
|
||||
|
||||
HOST_LABEL="$(hostname -s)"
|
||||
REBOOT_ON_SUCCESS=true
|
||||
TELEGRAM_ENABLED=true
|
||||
CHECK_PIP3=false
|
||||
DOCKER_IGNORE_IMAGES=()
|
||||
DOCKER_PINNED_IMAGES=()
|
||||
|
||||
declare -a ERRORS=()
|
||||
declare -a WARNINGS=()
|
||||
declare -a NOTES=()
|
||||
REBOOT_RECOMMENDED=false
|
||||
APT_CHANGED=false
|
||||
DOCKER_UPDATED=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-reboot) REBOOT_ON_SUCCESS=false ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Configurazione host (override via /etc/weekly-maintenance.conf) ---
|
||||
case "$(hostname -s)" in
|
||||
pi1)
|
||||
HOST_LABEL="Pi-1 (Master)"
|
||||
# Immagini build locali (no registry pubblico) → escluse da Watchtower
|
||||
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||
;;
|
||||
pi2)
|
||||
HOST_LABEL="Pi-2 (Backup)"
|
||||
CHECK_PIP3=true
|
||||
# Immagini build locali (no registry pubblico) → escluse da Watchtower
|
||||
DOCKER_IGNORE_IMAGES=(
|
||||
"irrigazione:latest"
|
||||
"turni-app:beta-latest"
|
||||
"turni-app:alpha-latest"
|
||||
"meteo-alert:latest"
|
||||
"loogle-casa:latest"
|
||||
"ewelink_smart_home:1.4.6"
|
||||
)
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -f "$CONF_FILE" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$CONF_FILE"
|
||||
fi
|
||||
|
||||
# --- Utility ---
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
append_report() {
|
||||
printf '%s\n' "$*" >> "$REPORT_FILE"
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local title="$1"
|
||||
shift
|
||||
local output
|
||||
local rc=0
|
||||
|
||||
log "▶ $title"
|
||||
append_report ""
|
||||
append_report "=== $title ==="
|
||||
|
||||
output="$("$@" 2>&1)" || rc=$?
|
||||
if [[ -n "$output" ]]; then
|
||||
append_report "$output"
|
||||
log "$output"
|
||||
fi
|
||||
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
NOTES+=("OK: $title")
|
||||
log "✓ $title completato"
|
||||
else
|
||||
ERRORS+=("$title (exit $rc)")
|
||||
log "✗ $title fallito (exit $rc)"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
load_telegram_token() {
|
||||
if [[ ! -f "$TOKEN_FILE" ]]; then
|
||||
WARNINGS+=("Token Telegram assente ($TOKEN_FILE): notifiche disabilitate")
|
||||
TELEGRAM_ENABLED=false
|
||||
return 1
|
||||
fi
|
||||
BOT_TOKEN=$(tr -d '\n\r' < "$TOKEN_FILE")
|
||||
if [[ -z "$BOT_TOKEN" ]]; then
|
||||
WARNINGS+=("Token Telegram vuoto: notifiche disabilitate")
|
||||
TELEGRAM_ENABLED=false
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
send_telegram() {
|
||||
local msg="$1"
|
||||
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
|
||||
curl -s --max-time 15 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
||||
-d "chat_id=${CHAT_ID}" \
|
||||
--data-urlencode "text=${msg}" \
|
||||
-d "parse_mode=Markdown" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
send_telegram_document() {
|
||||
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
|
||||
[[ -f "$REPORT_FILE" ]] || return 0
|
||||
curl -s --max-time 30 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \
|
||||
-F "chat_id=${CHAT_ID}" \
|
||||
-F "document=@${REPORT_FILE}" \
|
||||
-F "caption=Report completo ${HOST_LABEL}" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
require_root() {
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "Eseguire come root (es. cron root)." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
image_is_ignored() {
|
||||
local image="$1"
|
||||
local ignored
|
||||
for ignored in "${DOCKER_IGNORE_IMAGES[@]}"; do
|
||||
[[ "$image" == "$ignored" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Elenco nomi container da escludere (immagini locali / no registry).
|
||||
# Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
|
||||
watchtower_disabled_containers() {
|
||||
local name image disabled=()
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
name=${line%%|*}
|
||||
image=${line#*|}
|
||||
if image_is_ignored "$image"; then
|
||||
disabled+=("$name")
|
||||
fi
|
||||
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
if ((${#disabled[@]} > 0)); then
|
||||
local IFS=,
|
||||
printf '%s' "${disabled[*]}"
|
||||
fi
|
||||
}
|
||||
|
||||
run_watchtower() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
|
||||
local disable_list
|
||||
disable_list=$(watchtower_disabled_containers)
|
||||
|
||||
log "▶ Watchtower (run-once, container aggiornabili da registry)"
|
||||
append_report ""
|
||||
append_report "=== Watchtower run-once ==="
|
||||
if [[ -n "$disable_list" ]]; then
|
||||
log "Watchtower: esclusi container locali: $disable_list"
|
||||
append_report "Esclusi (immagini locali): $disable_list"
|
||||
fi
|
||||
|
||||
local -a env_args=(
|
||||
-e WATCHTOWER_CLEANUP=true
|
||||
-e WATCHTOWER_ROLLING_RESTART=false
|
||||
-e "WATCHTOWER_TIMEOUT=${WATCHTOWER_TIMEOUT}"
|
||||
-e TZ=Europe/Rome
|
||||
)
|
||||
if [[ -n "$disable_list" ]]; then
|
||||
env_args+=(-e "WATCHTOWER_DISABLE_CONTAINERS=${disable_list}")
|
||||
fi
|
||||
|
||||
local output rc=0
|
||||
output=$(docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
"${env_args[@]}" \
|
||||
"$WATCHTOWER_IMAGE" \
|
||||
--run-once 2>&1) || rc=$?
|
||||
|
||||
if [[ -n "$output" ]]; then
|
||||
append_report "$output"
|
||||
printf '%s\n' "$output" | tail -30 | while IFS= read -r line; do log "$line"; done
|
||||
fi
|
||||
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
NOTES+=("Watchtower run-once OK")
|
||||
if grep -q "updated=[1-9]" <<< "$output"; then
|
||||
DOCKER_UPDATED=true
|
||||
REBOOT_RECOMMENDED=true
|
||||
NOTES+=("Watchtower: container aggiornati")
|
||||
fi
|
||||
else
|
||||
ERRORS+=("Watchtower run-once (exit $rc)")
|
||||
fi
|
||||
}
|
||||
|
||||
# Espande ${VAR:-default} (usato nei compose Turni). Altri ${VAR} → scarta.
|
||||
expand_compose_image() {
|
||||
local raw="$1"
|
||||
while [[ "$raw" =~ \$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\} ]]; do
|
||||
raw="${raw/${BASH_REMATCH[0]}/${BASH_REMATCH[2]}}"
|
||||
done
|
||||
[[ "$raw" == *'$'* ]] && return 1
|
||||
printf '%s' "$raw"
|
||||
}
|
||||
|
||||
# Build locali / no registry: non fare docker pull (fallirebbe o scaricherebbe un omonimo Hub).
|
||||
failover_image_is_local_build() {
|
||||
local image="$1"
|
||||
local name="${image%%:*}"
|
||||
image_is_ignored "$image" && return 0
|
||||
case "$name" in
|
||||
nodus-backend|nodus-frontend|loogle-casa|loogle-mcp|irrigazione|ewelink_smart_home|turni-app)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
collect_failover_registry_images() {
|
||||
local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
|
||||
local f line img expanded
|
||||
[[ -d "$dir" ]] || return 1
|
||||
while IFS= read -r f; do
|
||||
[[ -f "$f" ]] || continue
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
img=$(expand_compose_image "$line") || continue
|
||||
failover_image_is_local_build "$img" && continue
|
||||
printf '%s\n' "$img"
|
||||
done < <(sed -n 's/^[[:space:]]*image:[[:space:]]*//p' "$f" | sed 's/["'\'']//g')
|
||||
done < <(find "$dir" -type f \( -name '*.yml' -o -name '*.yaml' \) | sort)
|
||||
}
|
||||
|
||||
pull_failover_standby_images() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
|
||||
local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
|
||||
log "▶ Pull immagini failover (registry, anche senza container)"
|
||||
append_report ""
|
||||
append_report "=== Pull immagini failover (standby) ==="
|
||||
append_report "Compose: $dir"
|
||||
|
||||
if [[ ! -d "$dir" ]]; then
|
||||
WARNINGS+=("Directory compose failover assente: $dir")
|
||||
append_report "Directory assente"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local -a images=()
|
||||
local img
|
||||
while IFS= read -r img; do
|
||||
[[ -z "$img" ]] && continue
|
||||
images+=("$img")
|
||||
done < <(collect_failover_registry_images | sort -u)
|
||||
|
||||
if ((${#images[@]} == 0)); then
|
||||
WARNINGS+=("Nessuna immagine registry nei compose failover")
|
||||
append_report "Lista vuota"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pulled=0 failed=0 skipped=0
|
||||
local before after
|
||||
for img in "${images[@]}"; do
|
||||
before=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true)
|
||||
if docker pull "$img" >> "$REPORT_FILE" 2>&1; then
|
||||
after=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true)
|
||||
if [[ -n "$before" && "$before" == "$after" ]]; then
|
||||
skipped=$((skipped + 1))
|
||||
log " = $img (già aggiornata)"
|
||||
append_report "unchanged: $img"
|
||||
else
|
||||
pulled=$((pulled + 1))
|
||||
log " ↑ $img"
|
||||
append_report "updated: $img"
|
||||
fi
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
WARNINGS+=("docker pull fallito: $img")
|
||||
log " ✗ $img"
|
||||
fi
|
||||
done
|
||||
|
||||
NOTES+=("Failover images: ${#images[@]} registry, $pulled aggiornate, $skipped già ok, $failed errori")
|
||||
append_report "Riepilogo: ${#images[@]} immagini, aggiornate=$pulled, invariate=$skipped, errori=$failed"
|
||||
log "✓ Pull failover: aggiornate=$pulled invariate=$skipped errori=$failed"
|
||||
}
|
||||
|
||||
audit_apt() {
|
||||
local holds
|
||||
holds=$(apt-mark showhold 2>/dev/null || true)
|
||||
if [[ -n "$holds" ]]; then
|
||||
WARNINGS+=("Pacchetti apt bloccati (hold): ${holds//$'\n'/, }")
|
||||
append_report "$holds"
|
||||
fi
|
||||
|
||||
local upgradable
|
||||
upgradable=$(apt list --upgradable 2>/dev/null | tail -n +2 || true)
|
||||
if [[ -n "$upgradable" ]]; then
|
||||
WARNINGS+=("Restano pacchetti non aggiornati dopo full-upgrade")
|
||||
append_report "$upgradable"
|
||||
fi
|
||||
}
|
||||
|
||||
audit_pihole_versions() {
|
||||
local out
|
||||
out=$("$PIHOLE_BIN" -v 2>&1 || true)
|
||||
append_report "$out"
|
||||
if grep -qiE 'version is .*\(Latest: .*\)' <<< "$out"; then
|
||||
while IFS= read -r line; do
|
||||
local current latest
|
||||
current=$(sed -n 's/.*Version is \([^ ]*\).*/\1/p' <<< "$line")
|
||||
latest=$(sed -n 's/.*Latest: \([^)]*\).*/\1/p' <<< "$line")
|
||||
if [[ -n "$current" && -n "$latest" && "$current" != "$latest" && "$current" != "N/A" ]]; then
|
||||
WARNINGS+=("Pi-hole non allineato: $line")
|
||||
fi
|
||||
done <<< "$out"
|
||||
fi
|
||||
}
|
||||
|
||||
audit_eeprom() {
|
||||
command -v rpi-eeprom-update >/dev/null 2>&1 || return 0
|
||||
local out
|
||||
out=$(rpi-eeprom-update 2>&1 || true)
|
||||
append_report "$out"
|
||||
if grep -qiE 'UPDATE REQUIRED|update available|NEW EEPROM' <<< "$out"; then
|
||||
log "EEPROM: aggiornamento disponibile, applico..."
|
||||
append_report ""
|
||||
append_report "=== rpi-eeprom-update -a ==="
|
||||
if rpi-eeprom-update -a >> "$REPORT_FILE" 2>&1; then
|
||||
NOTES+=("EEPROM aggiornato")
|
||||
REBOOT_RECOMMENDED=true
|
||||
else
|
||||
ERRORS+=("rpi-eeprom-update -a")
|
||||
fi
|
||||
else
|
||||
NOTES+=("EEPROM: aggiornato")
|
||||
fi
|
||||
}
|
||||
|
||||
audit_docker() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
|
||||
append_report ""
|
||||
append_report "=== Docker audit ==="
|
||||
|
||||
local exited
|
||||
exited=$(docker ps -a --filter "status=exited" --format '{{.Names}} ({{.Image}})' 2>/dev/null || true)
|
||||
if [[ -n "$exited" ]]; then
|
||||
WARNINGS+=("Container Docker fermati (Exited): ${exited//$'\n'/, }")
|
||||
append_report "Exited:"
|
||||
append_report "$exited"
|
||||
fi
|
||||
|
||||
local name image
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
name=${line%%|*}
|
||||
image=${line#*|}
|
||||
|
||||
image_is_ignored "$image" && continue
|
||||
|
||||
if [[ "$image" =~ ^[0-9a-f]{12}$ ]]; then
|
||||
WARNINGS+=("Container '$name' usa immagine per ID ($image): preferire un tag versionato")
|
||||
fi
|
||||
|
||||
local pinned
|
||||
for pinned in "${DOCKER_PINNED_IMAGES[@]}"; do
|
||||
if [[ "$image" == "$pinned" ]]; then
|
||||
WARNINGS+=("Container '$name' usa tag bloccato ($pinned): Watchtower non passerà a :latest")
|
||||
fi
|
||||
done
|
||||
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
}
|
||||
|
||||
audit_pip3() {
|
||||
[[ "$CHECK_PIP3" == true ]] || return 0
|
||||
command -v pip3 >/dev/null 2>&1 || return 0
|
||||
|
||||
append_report ""
|
||||
append_report "=== pip3 outdated (sistema) ==="
|
||||
local outdated count
|
||||
outdated=$(pip3 list --outdated --format=columns 2>/dev/null | tail -n +3 || true)
|
||||
if [[ -n "$outdated" ]]; then
|
||||
count=$(wc -l <<< "$outdated")
|
||||
append_report "$outdated"
|
||||
WARNINGS+=("pip3: $count pacchetti Python di sistema non aggiornati (aggiornamento manuale/consigliato in venv)")
|
||||
else
|
||||
NOTES+=("pip3: tutti aggiornati")
|
||||
fi
|
||||
}
|
||||
|
||||
build_telegram_summary() {
|
||||
local icon status
|
||||
if ((${#ERRORS[@]} > 0)); then
|
||||
icon="🚨"
|
||||
status="ERRORI"
|
||||
elif ((${#WARNINGS[@]} > 0)); then
|
||||
icon="⚠️"
|
||||
status="WARNINGS"
|
||||
else
|
||||
icon="✅"
|
||||
status="OK"
|
||||
fi
|
||||
|
||||
local msg="${icon} *Manutenzione settimanale ${HOST_LABEL}*
|
||||
Stato: *${status}*
|
||||
Data: $(date '+%d/%m/%Y %H:%M')"
|
||||
|
||||
if [[ "$APT_CHANGED" == true ]]; then
|
||||
msg+="
|
||||
📦 apt: pacchetti aggiornati"
|
||||
fi
|
||||
if [[ "$DOCKER_UPDATED" == true ]]; then
|
||||
msg+="
|
||||
🐳 Docker: container aggiornati"
|
||||
fi
|
||||
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
|
||||
msg+="
|
||||
🔄 Reboot host programmato tra ${REBOOT_DELAY_MIN} min (fine manutenzione)"
|
||||
fi
|
||||
|
||||
if ((${#ERRORS[@]} > 0)); then
|
||||
msg+="
|
||||
*Errori (${#ERRORS[@]}):*"
|
||||
local e
|
||||
for e in "${ERRORS[@]}"; do
|
||||
msg+="
|
||||
• ${e}"
|
||||
done
|
||||
fi
|
||||
|
||||
if ((${#WARNINGS[@]} > 0)); then
|
||||
msg+="
|
||||
*Avvisi (${#WARNINGS[@]}):*"
|
||||
local w
|
||||
local n=0
|
||||
for w in "${WARNINGS[@]}"; do
|
||||
((n++)) || true
|
||||
[[ $n -le 8 ]] && msg+="
|
||||
• ${w}"
|
||||
done
|
||||
if ((${#WARNINGS[@]} > 8)); then
|
||||
msg+="
|
||||
• … +$((${#WARNINGS[@]} - 8)) avvisi (vedi report)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ((${#ERRORS[@]} == 0 && ${#WARNINGS[@]} == 0)); then
|
||||
msg+="
|
||||
Tutto aggiornato. Log: \`${LOG_FILE}\`"
|
||||
else
|
||||
msg+="
|
||||
Report completo allegato o in \`${REPORT_FILE}\`"
|
||||
fi
|
||||
|
||||
send_telegram "$msg"
|
||||
if ((${#ERRORS[@]} > 0 || ${#WARNINGS[@]} > 0)); then
|
||||
send_telegram_document
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
require_root
|
||||
|
||||
: > "$REPORT_FILE"
|
||||
log "========== Inizio manutenzione settimanale ($HOST_LABEL) =========="
|
||||
append_report "Manutenzione settimanale - $HOST_LABEL"
|
||||
append_report "Avviata: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
append_report "Host: $(hostname -f) ($(hostname -I | awk '{print $1}'))"
|
||||
|
||||
load_telegram_token || true
|
||||
|
||||
# 1. Aggiornamento pacchetti sistema
|
||||
if apt update >> "$REPORT_FILE" 2>&1; then
|
||||
NOTES+=("apt update OK")
|
||||
log "✓ apt update"
|
||||
else
|
||||
ERRORS+=("apt update")
|
||||
log "✗ apt update fallito"
|
||||
fi
|
||||
|
||||
BEFORE_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
|
||||
if apt full-upgrade -y >> "$REPORT_FILE" 2>&1; then
|
||||
NOTES+=("apt full-upgrade OK")
|
||||
log "✓ apt full-upgrade"
|
||||
AFTER_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
|
||||
if [[ "$BEFORE_UPGRADES" -gt "$AFTER_UPGRADES" ]] || [[ "$BEFORE_UPGRADES" -gt 0 ]]; then
|
||||
APT_CHANGED=true
|
||||
REBOOT_RECOMMENDED=true
|
||||
fi
|
||||
else
|
||||
ERRORS+=("apt full-upgrade")
|
||||
log "✗ apt full-upgrade fallito"
|
||||
fi
|
||||
|
||||
run_step "apt autoremove" apt autoremove -y
|
||||
audit_apt
|
||||
|
||||
# 2. Pi-hole
|
||||
if [[ -x "$PIHOLE_BIN" ]]; then
|
||||
run_step "pihole -up" "$PIHOLE_BIN" -up
|
||||
audit_pihole_versions
|
||||
else
|
||||
ERRORS+=("pihole non trovato in $PIHOLE_BIN")
|
||||
fi
|
||||
|
||||
# 3. EEPROM firmware
|
||||
audit_eeprom
|
||||
|
||||
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
|
||||
run_watchtower
|
||||
# 4b. Immagini registry dei compose failover (Paperless, Vaultwarden, …) anche se non c'è container
|
||||
pull_failover_standby_images
|
||||
|
||||
# 5. Audit residui
|
||||
audit_docker
|
||||
audit_pip3
|
||||
|
||||
append_report ""
|
||||
append_report "=== Riepilogo ==="
|
||||
append_report "Errori: ${#ERRORS[@]}"
|
||||
append_report "Avvisi: ${#WARNINGS[@]}"
|
||||
if ((${#ERRORS[@]} > 0)); then
|
||||
append_report "Dettaglio errori:"
|
||||
printf '%s\n' "${ERRORS[@]}" >> "$REPORT_FILE"
|
||||
fi
|
||||
if ((${#WARNINGS[@]} > 0)); then
|
||||
append_report "Dettaglio avvisi:"
|
||||
printf '%s\n' "${WARNINGS[@]}" >> "$REPORT_FILE"
|
||||
fi
|
||||
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
|
||||
# 5b. Il peer deve poter coprire: niente reboot se è giù o in manutenzione
|
||||
# (31/10: Pi-1 riavviato alle 04:50 con Pi-2 già giù → rete senza nessun nodo).
|
||||
REBOOT_GUARD="/home/daniely/rete/scripts/maintenance-reboot-guard.sh"
|
||||
if [[ "$REBOOT_ON_SUCCESS" == true && -x "$REBOOT_GUARD" ]]; then
|
||||
if guard_msg="$("$REBOOT_GUARD" check 2>&1)"; then
|
||||
log "Guardia reboot: ${guard_msg}"
|
||||
else
|
||||
REBOOT_ON_SUCCESS=false
|
||||
WARNINGS+=("Reboot saltato: ${guard_msg}")
|
||||
log "⚠ Reboot saltato: ${guard_msg}"
|
||||
fi
|
||||
fi
|
||||
|
||||
build_telegram_summary
|
||||
|
||||
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
|
||||
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
|
||||
log "Reboot host programmato tra ${REBOOT_DELAY_MIN} minuti (fine manutenzione settimanale)"
|
||||
append_report ""
|
||||
append_report "=== Reboot ==="
|
||||
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
|
||||
# Annuncio al peer: prende il VIP ma non pivota le app, e se il nodo non
|
||||
# torna entro il TTL il suo watchdog fa il failover vero.
|
||||
[[ -x "$REBOOT_GUARD" ]] && "$REBOOT_GUARD" announce "$REBOOT_DELAY_MIN" || true
|
||||
/usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
|
||||
ERRORS+=("shutdown -r (reboot programmato)")
|
||||
log "✗ Impossibile programmare il reboot"
|
||||
}
|
||||
else
|
||||
log "Reboot disabilitato (--no-reboot o REBOOT_ON_SUCCESS=false)"
|
||||
append_report ""
|
||||
append_report "=== Reboot ==="
|
||||
append_report "Saltato (disabilitato)"
|
||||
fi
|
||||
|
||||
log "========== Fine manutenzione settimanale =========="
|
||||
exit $(( ${#ERRORS[@]} > 0 ? 1 : 0 ))
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/bin/bash
|
||||
# raspiBackup post extension — notifica Loogle Casa (solo WebApp).
|
||||
#
|
||||
# RaspiBackup chiama il post hook PRIMA di STARTSERVICES: su Pi2 Casa è giù
|
||||
# (before-stop ferma loogle-casa, STOPSERVICES ferma docker). Non notificare
|
||||
# subito: systemd-run stacca dal cgroup e riprova finché l'API risponde.
|
||||
#
|
||||
# Sourced da raspiBackup: $1 = return code backup.
|
||||
# Eseguito da systemd-run: $1 = --wait-send
|
||||
|
||||
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
||||
MAX_TRIES="${LOOGLE_BACKUP_NOTIFY_TRIES:-36}"
|
||||
SLEEP_SECS="${LOOGLE_BACKUP_NOTIFY_SLEEP:-5}"
|
||||
|
||||
wait_send() {
|
||||
local title="$1" body="$2" severity="$3" tag="$4"
|
||||
local i
|
||||
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||
echo "loogle-notify.sh assente" >&2
|
||||
exit 0
|
||||
fi
|
||||
for ((i = 1; i <= MAX_TRIES; i++)); do
|
||||
if "$LOOGLE_NOTIFY" --title "$title" --body "$body" --category raspi_backup \
|
||||
--severity "$severity" --tag "$tag"; then
|
||||
echo "notify ok try=$i tag=$tag"
|
||||
exit 0
|
||||
fi
|
||||
echo "notify retry $i/$MAX_TRIES tag=$tag" >&2
|
||||
sleep "$SLEEP_SECS"
|
||||
done
|
||||
echo "notify failed after ${MAX_TRIES} tries tag=$tag" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--wait-send" ]]; then
|
||||
wait_send "${2:-}" "${3:-}" "${4:-info}" "${5:-raspi_backup}"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
RC="${1:-1}"
|
||||
HOST="${HOSTNAME:-$(hostname)}"
|
||||
HOST="${HOST%%.*}"
|
||||
|
||||
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||
return 0 2>/dev/null || exit 0
|
||||
fi
|
||||
|
||||
if (( RC == 0 )); then
|
||||
TITLE="Backup completato"
|
||||
BODY="Backup di ${HOST} terminato con successo."
|
||||
SEVERITY="info"
|
||||
else
|
||||
TITLE="Backup fallito"
|
||||
BODY="Backup di ${HOST} fallito (codice ${RC})."
|
||||
SEVERITY="error"
|
||||
fi
|
||||
|
||||
TAG="raspi_backup-${HOST}"
|
||||
SELF="$(readlink -f "${BASH_SOURCE[0]:-$0}" 2>/dev/null || echo /usr/local/bin/raspiBackup_loogle_post.sh)"
|
||||
|
||||
# Stacca dal servizio raspiBackup (KillMode=control-group) e aspetta Casa.
|
||||
if command -v systemd-run >/dev/null 2>&1; then
|
||||
systemd-run --quiet --collect \
|
||||
--description="Loogle Casa raspiBackup notify ${HOST}" \
|
||||
/bin/bash "$SELF" --wait-send "$TITLE" "$BODY" "$SEVERITY" "$TAG" \
|
||||
|| true
|
||||
else
|
||||
nohup /bin/bash "$SELF" --wait-send "$TITLE" "$BODY" "$SEVERITY" "$TAG" \
|
||||
>/dev/null 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
fi
|
||||
|
||||
return 0 2>/dev/null || exit 0
|
||||
@@ -1,85 +0,0 @@
|
||||
# Generato da .env.example — personalizza prima del deploy
|
||||
MCP_BASE_URL=https://mcp.loogle.it
|
||||
MCP_PORT=8700
|
||||
MCP_JWT_SECRET='k9#mP2$vQ8!zX5_wL3*bF7%hR4&jN6+yT1-pC9@dG2#fK5^sV8~xL4?hZ1'
|
||||
MCP_OAUTH_CLIENT_ID=loogle-mcp-public
|
||||
|
||||
PAPERLESS_URL=https://docs.loogle.it
|
||||
# Token API utente (docs.loogle.it → Profilo → Token API) — vedi docs/PAPERLESS-TOKEN.md
|
||||
# Opzionale fallback admin: PAPERLESS_API_TOKEN=
|
||||
PAPERLESS_API_TOKEN_DANIELE=d75d378c1ce654578956eb595c0436cb96213b5c
|
||||
PAPERLESS_API_TOKEN_LUCIA=b3124dab2d42f230e503adafaac7ff42551c1c56
|
||||
PAPERLESS_API_TOKEN_DAVIDE=c50ecb5780eee62d5882bff980dbed8c7cf1b860
|
||||
PAPERLESS_API_TOKEN_LUCA=45792e52a58d7f2a34fcf0f237b81770ac2f8fdc
|
||||
|
||||
# Gitea API — vedi docs/GITEA-TOKEN.md
|
||||
GITEA_URL=https://git.loogle.it
|
||||
GITEA_API_URL=http://192.168.128.81:3002
|
||||
GITEA_API_TOKEN_DANIELE=86faeed42981e855762b34f86b53dc342766fa44
|
||||
GITEA_API_TOKEN_LUCIA=478e0ac9436b412357fab144710ff74ab927e7fd
|
||||
GITEA_API_TOKEN_DAVIDE=36a097519686ce64cc1103f66691555adbc3735d
|
||||
GITEA_API_TOKEN_LUCA=051239ae1e8e60a89619249abed1b65550d29414
|
||||
|
||||
# Qdrant remoto su DS920 (consigliato) — lasciare vuoto per fallback SQLite su Pi
|
||||
QDRANT_URL=http://192.168.128.100:6333
|
||||
# Qdrant locale disabilitato su Pi5 (page size 16K) — vedi compose profile qdrant-local
|
||||
OLLAMA_URL=http://192.168.128.100:11434
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_EMBED_MODEL=text-embedding-3-small
|
||||
INDEXER_INTERVAL_MINUTES=30
|
||||
|
||||
# Thermal gate DS920 — profilo lento/regolare (1 core Ollama + delay lunghi)
|
||||
# Soft anticipato: un embed può alzare la temp di diversi °C
|
||||
THERMAL_GATE_ENABLED=yes
|
||||
THERMAL_TEMP_SOFT_C=58
|
||||
THERMAL_TEMP_HARD_C=70
|
||||
THERMAL_CPU_TARGET_PCT=40
|
||||
THERMAL_POLL_S=30
|
||||
THERMAL_RESUME_MARGIN_C=2
|
||||
DS920_THERMAL_URL=http://192.168.128.100:9191/thermal
|
||||
OLLAMA_NUM_THREAD=1
|
||||
OLLAMA_EMBED_DELAY_S=10
|
||||
OLLAMA_EMBED_COOL_DELAY_S=3
|
||||
OLLAMA_KEEP_ALIVE_DEFAULT=120
|
||||
OLLAMA_KEEP_ALIVE_COOL=300
|
||||
# no = non scaricare il modello per HARD (container sempre su; solo pausa indexer)
|
||||
OLLAMA_UNLOAD_ON_HARD=no
|
||||
|
||||
# RAG Gitea (P3)
|
||||
GITEA_INDEX_ENABLED=yes
|
||||
GITEA_INDEX_REPOS_DANIELE=daniele/rete,daniele/loogle-scripts
|
||||
GITEA_INDEX_REPOS_LUCIA=lucia/nodus,lucia/progetti
|
||||
GITEA_INDEX_REPOS_DAVIDE=davide/progetti
|
||||
GITEA_INDEX_REPOS_LUCA=luca/progetti
|
||||
GITEA_INDEX_MAX_FILES_PER_REPO=40
|
||||
GITEA_INDEX_MAX_FILE_BYTES=120000
|
||||
|
||||
# P5 — Loogle Casa + Home Assistant (read-only)
|
||||
LOOGLE_CASA_URL=https://casa.loogle.it
|
||||
LOOGLE_CASA_API_URL=http://192.168.128.81:5602
|
||||
LOOGLE_CASA_PASSWORD_DANIELE=@Dedelove1
|
||||
LOOGLE_CASA_PASSWORD_LUCIA=lucia
|
||||
LOOGLE_CASA_PASSWORD_DAVIDE=davide
|
||||
LOOGLE_CASA_PASSWORD_LUCA=luca
|
||||
|
||||
HA_URL=https://ha.loogle.it
|
||||
HA_API_URL=http://192.168.128.81:8123
|
||||
HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI0ZGVhYzczOGYzMTA0Y2QzYTQ3YTViNDRlZTY2NjNhNiIsImlhdCI6MTc4MTE2NjE3NSwiZXhwIjoyMDk2NTI2MTc1fQ.2sou8f41sEcN_gn1NxJfb1MLNftMVHAsReQB0hP-jPo
|
||||
|
||||
# P6 — Irrigazione + Turni (read-only)
|
||||
IRRIGAZIONE_URL=https://irri.loogle.it
|
||||
IRRIGAZIONE_API_URL=http://192.168.128.81:5601
|
||||
IRRIGAZIONE_PASSWORD_DANIELE=@Dedelove1
|
||||
IRRIGAZIONE_PASSWORD_LUCIA=lucia
|
||||
IRRIGAZIONE_PASSWORD_DAVIDE=dado
|
||||
IRRIGAZIONE_PASSWORD_LUCA=luca
|
||||
|
||||
TURNI_URL=https://turni.loogle.it
|
||||
TURNI_API_URL=https://turni.loogle.it
|
||||
TURNI_PASSWORD_DANIELE=@Dedelove1
|
||||
|
||||
# P7 — RAG Irrigazione + Turni
|
||||
APPS_INDEX_ENABLED=yes
|
||||
APPS_INDEX_USER=daniele
|
||||
@@ -1,81 +0,0 @@
|
||||
# Loogle MCP Hub — copia in .env e personalizza
|
||||
MCP_BASE_URL=https://mcp.loogle.it
|
||||
MCP_PORT=8700
|
||||
MCP_JWT_SECRET=change-me-to-a-long-random-string
|
||||
MCP_OAUTH_CLIENT_ID=loogle-mcp-public
|
||||
|
||||
# Paperless API — vedi docs/PAPERLESS-TOKEN.md
|
||||
PAPERLESS_URL=https://docs.loogle.it
|
||||
# Fallback admin (opzionale se usi token per utente sotto)
|
||||
PAPERLESS_API_TOKEN=
|
||||
PAPERLESS_API_TOKEN_DANIELE=
|
||||
PAPERLESS_API_TOKEN_LUCIA=
|
||||
PAPERLESS_API_TOKEN_DAVIDE=
|
||||
PAPERLESS_API_TOKEN_LUCA=
|
||||
|
||||
# Gitea API — vedi docs/GITEA-TOKEN.md
|
||||
GITEA_URL=https://git.loogle.it
|
||||
# Opzionale: URL API raggiungibile dal container MCP (es. http://192.168.128.81:3002)
|
||||
# GITEA_API_URL=
|
||||
GITEA_API_TOKEN=
|
||||
GITEA_API_TOKEN_DANIELE=
|
||||
GITEA_API_TOKEN_LUCIA=
|
||||
GITEA_API_TOKEN_DAVIDE=
|
||||
GITEA_API_TOKEN_LUCA=
|
||||
|
||||
# Qdrant (container locale o remoto)
|
||||
QDRANT_URL=http://192.168.128.100:6333
|
||||
|
||||
# Embedding via Ollama su DS920+
|
||||
OLLAMA_URL=http://192.168.128.100:11434
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||
|
||||
# Fallback cloud embedding (opzionale)
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_EMBED_MODEL=text-embedding-3-small
|
||||
|
||||
# Indicizzazione
|
||||
INDEXER_INTERVAL_MINUTES=30
|
||||
|
||||
# Thermal gate DS920 — evita overheating durante embedding
|
||||
THERMAL_GATE_ENABLED=yes
|
||||
THERMAL_TEMP_SOFT_C=58
|
||||
THERMAL_TEMP_HARD_C=70
|
||||
THERMAL_CPU_TARGET_PCT=40
|
||||
THERMAL_POLL_S=30
|
||||
THERMAL_RESUME_MARGIN_C=2
|
||||
DS920_THERMAL_URL=http://192.168.128.100:9191/thermal
|
||||
OLLAMA_NUM_THREAD=1
|
||||
OLLAMA_EMBED_DELAY_S=10
|
||||
OLLAMA_EMBED_COOL_DELAY_S=3
|
||||
|
||||
# RAG Gitea (P3) — indicizza markdown/codice da git.loogle.it
|
||||
GITEA_INDEX_ENABLED=yes
|
||||
# Opzionale: limita repo per utente (CSV). Vuoto = tutti i repo accessibili via API
|
||||
# GITEA_INDEX_REPOS_DANIELE=daniele/rete,daniele/loogle-scripts
|
||||
# GITEA_INDEX_REPOS_DAVIDE=davide/progetti
|
||||
# GITEA_INDEX_REPOS_LUCA=luca/progetti
|
||||
GITEA_INDEX_MAX_FILES_PER_REPO=150
|
||||
GITEA_INDEX_MAX_FILE_BYTES=120000
|
||||
|
||||
# P5 — Loogle Casa + Home Assistant (read-only)
|
||||
LOOGLE_CASA_URL=https://casa.loogle.it
|
||||
# LOOGLE_CASA_API_URL=http://192.168.128.81:5602
|
||||
LOOGLE_CASA_PASSWORD_DANIELE=
|
||||
LOOGLE_CASA_PASSWORD_LUCIA=
|
||||
LOOGLE_CASA_PASSWORD_DAVIDE=
|
||||
LOOGLE_CASA_PASSWORD_LUCA=
|
||||
HA_URL=https://ha.loogle.it
|
||||
# HA_API_URL=http://192.168.128.81:8123
|
||||
HA_TOKEN=
|
||||
|
||||
# P6 — Irrigazione + Turni (read-only)
|
||||
IRRIGAZIONE_URL=https://irri.loogle.it
|
||||
# IRRIGAZIONE_API_URL=http://192.168.128.81:5601
|
||||
IRRIGAZIONE_PASSWORD_DANIELE=
|
||||
TURNI_URL=https://turni.loogle.it
|
||||
TURNI_PASSWORD_DANIELE=
|
||||
|
||||
# P7 — RAG Irrigazione + Turni
|
||||
APPS_INDEX_ENABLED=yes
|
||||
APPS_INDEX_USER=daniele
|
||||
@@ -1,6 +0,0 @@
|
||||
data/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pyc
|
||||
__pycache__/
|
||||
@@ -1,24 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 TZ=Europe/Rome
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /srv
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY worker ./worker
|
||||
COPY scripts ./scripts
|
||||
COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
ENV PYTHONPATH=/srv
|
||||
VOLUME /data
|
||||
EXPOSE 8700
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8700"]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Loogle MCP Hub
|
||||
|
||||
Piattaforma MCP multi-utente per homelab LOOGLE.IT.
|
||||
|
||||
## Documentazione
|
||||
|
||||
| Documento | Per chi |
|
||||
|-----------|---------|
|
||||
| [ARCHITETTURA.md](docs/ARCHITETTURA.md) | Architettura, parametri, DNS, NPM, env |
|
||||
| [GUIDA-ADMIN.md](docs/GUIDA-ADMIN.md) | Daniele — gestione e uso avanzato |
|
||||
| [GUIDA-UTENTI.md](docs/GUIDA-UTENTI.md) | Lucia, Davide, Luca — client AI e uso quotidiano |
|
||||
| [ONBOARDING.md](docs/ONBOARDING.md) | Dettaglio tecnico connector MCP |
|
||||
|
||||
## URL
|
||||
|
||||
- MCP: https://mcp.loogle.it/mcp
|
||||
- Dashboard: https://mcp.loogle.it/dashboard
|
||||
|
||||
## Setup DNS + NPM
|
||||
|
||||
```bash
|
||||
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh
|
||||
```
|
||||
Whitespace-only changes.
@@ -1,34 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Audit log for MCP tool invocations."""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from .db import get_conn
|
||||
|
||||
|
||||
def log_tool(username: str, tool_name: str, resource_id: Optional[str] = None, detail: Optional[dict] = None) -> None:
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log(username,tool_name,resource_id,detail) VALUES (?,?,?,?)",
|
||||
(username, tool_name, resource_id, json.dumps(detail or {}, ensure_ascii=False)),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM audit_log WHERE id NOT IN "
|
||||
"(SELECT id FROM audit_log ORDER BY id DESC LIMIT 5000)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def list_audit(limit: int = 100, username: Optional[str] = None) -> list:
|
||||
conn = get_conn()
|
||||
if username:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM audit_log WHERE username=? ORDER BY id DESC LIMIT ?",
|
||||
(username, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM audit_log ORDER BY id DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -1,146 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Autenticazione utenti famiglia — pattern Loogle Casa."""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .db import get_conn
|
||||
|
||||
SESSION_DAYS = 30
|
||||
PBKDF2_ITER = 240_000
|
||||
MAX_ATTEMPTS = 8
|
||||
WINDOW_S = 600
|
||||
_attempts: dict = {}
|
||||
|
||||
FAMILY_USERS = (
|
||||
("daniele", True),
|
||||
("lucia", False),
|
||||
("davide", False),
|
||||
("luca", False),
|
||||
)
|
||||
|
||||
DEFAULT_SCOPES = (
|
||||
"context:read context:write knowledge:read knowledge:write gitea:read gitea:write"
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = os.urandom(16)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITER)
|
||||
return "pbkdf2$%d$%s$%s" % (
|
||||
PBKDF2_ITER,
|
||||
base64.b64encode(salt).decode(),
|
||||
base64.b64encode(dk).decode(),
|
||||
)
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
_, iters, salt_b64, dk_b64 = stored.split("$")
|
||||
salt = base64.b64decode(salt_b64)
|
||||
expected = base64.b64decode(dk_b64)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, int(iters))
|
||||
return hmac.compare_digest(dk, expected)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_family_users() -> None:
|
||||
conn = get_conn()
|
||||
for username, is_admin in FAMILY_USERS:
|
||||
row = conn.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()
|
||||
if row:
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO users(username,password_hash,is_admin,must_change_password)"
|
||||
" VALUES (?,?,?,1)",
|
||||
(username, hash_password(username), 1 if is_admin else 0),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def throttle(ip: str) -> None:
|
||||
now = time.time()
|
||||
hist = [t for t in _attempts.get(ip, []) if now - t < WINDOW_S]
|
||||
_attempts[ip] = hist
|
||||
if len(hist) >= MAX_ATTEMPTS:
|
||||
raise HTTPException(429, "Troppi tentativi: riprova tra qualche minuto")
|
||||
|
||||
|
||||
def record_attempt(ip: str) -> None:
|
||||
_attempts.setdefault(ip, []).append(time.time())
|
||||
|
||||
|
||||
def authenticate(username: str, password: str) -> Optional[dict]:
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone()
|
||||
if not row or not verify_password(password, row["password_hash"]):
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
def get_user_by_id(user_id: int) -> Optional[dict]:
|
||||
row = get_conn().execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_user_by_username(username: str) -> Optional[dict]:
|
||||
row = get_conn().execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def change_password(user_id: int, old_password: str, new_password: str) -> bool:
|
||||
row = get_conn().execute("SELECT password_hash FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row or not verify_password(old_password, row["password_hash"]):
|
||||
return False
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash=?, must_change_password=0 WHERE id=?",
|
||||
(hash_password(new_password), user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
|
||||
def current_user_from_cookie(request: Request) -> dict:
|
||||
token = request.cookies.get("mcp_session", "")
|
||||
if not token:
|
||||
raise HTTPException(401, "Non autenticato")
|
||||
row = get_conn().execute(
|
||||
"SELECT u.id,u.username,u.is_admin,u.must_change_password"
|
||||
" FROM sessions s JOIN users u ON u.id=s.user_id"
|
||||
" WHERE s.token=? AND s.expires_at > datetime('now')",
|
||||
(token,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(401, "Sessione scaduta")
|
||||
return dict(row)
|
||||
|
||||
|
||||
def require_admin(request: Request) -> dict:
|
||||
user = current_user_from_cookie(request)
|
||||
if not user["is_admin"]:
|
||||
raise HTTPException(403, "Riservato all'amministratore")
|
||||
return user
|
||||
|
||||
|
||||
def ensure_sessions_table() -> None:
|
||||
get_conn().execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
get_conn().commit()
|
||||
Whitespace-only changes.
@@ -1,154 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Collegamento progetti MCP ↔ repository Gitea."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from ..knowledge import gitea
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.context.gitea_link")
|
||||
|
||||
README_CANDIDATES = ("README.md", "readme.md", "Readme.md", "README.MD")
|
||||
DOCS_DIR = "docs"
|
||||
MAX_README_CHARS = 12_000
|
||||
MAX_DOC_FILES = 25
|
||||
|
||||
|
||||
def normalize_gitea_repo(repo: Optional[str]) -> Optional[str]:
|
||||
if repo is None:
|
||||
return None
|
||||
cleaned = repo.strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
owner, name = gitea.parse_repo(cleaned)
|
||||
return f"{owner}/{name}"
|
||||
|
||||
|
||||
def verify_repo_access(username: str, repo: str) -> None:
|
||||
owner, name = gitea.parse_repo(repo)
|
||||
gitea._request("GET", f"/repos/{owner}/{name}", username=username)
|
||||
|
||||
|
||||
def fetch_readme(username: str, repo: str) -> Optional[dict]:
|
||||
for path in README_CANDIDATES:
|
||||
try:
|
||||
data = gitea.get_file(repo, path, username=username)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except Exception as exc:
|
||||
LOGGER.warning("README %s/%s non leggibile: %s", repo, path, exc)
|
||||
continue
|
||||
if data.get("type") != "file":
|
||||
continue
|
||||
content = (data.get("content") or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
truncated = len(content) > MAX_README_CHARS
|
||||
return {
|
||||
"path": path,
|
||||
"sha": data.get("sha"),
|
||||
"html_url": data.get("html_url"),
|
||||
"content": content[:MAX_README_CHARS],
|
||||
"truncated": truncated,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def fetch_docs_index(username: str, repo: str) -> list[dict]:
|
||||
try:
|
||||
data = gitea.get_file(repo, DOCS_DIR, username=username)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Directory docs/ non leggibile per %s: %s", repo, exc)
|
||||
return []
|
||||
|
||||
if data.get("type") != "dir":
|
||||
return []
|
||||
|
||||
entries = []
|
||||
for item in data.get("entries") or []:
|
||||
if item.get("type") != "file":
|
||||
continue
|
||||
path = item.get("path") or ""
|
||||
if not path.lower().endswith((".md", ".txt", ".rst")):
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"path": path,
|
||||
"size": item.get("size"),
|
||||
}
|
||||
)
|
||||
if len(entries) >= MAX_DOC_FILES:
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def project_enrichment(username: str, gitea_repo: str) -> dict:
|
||||
repo = normalize_gitea_repo(gitea_repo)
|
||||
if not repo:
|
||||
return {"linked": False}
|
||||
|
||||
base = {
|
||||
"linked": True,
|
||||
"repo": repo,
|
||||
"html_url": f"{gitea.public_base_url()}/{repo}",
|
||||
}
|
||||
|
||||
if not gitea.is_configured(username):
|
||||
return {
|
||||
**base,
|
||||
"available": False,
|
||||
"error": "Gitea non configurato per questo utente",
|
||||
}
|
||||
|
||||
try:
|
||||
verify_repo_access(username, repo)
|
||||
readme = fetch_readme(username, repo)
|
||||
docs = fetch_docs_index(username, repo)
|
||||
open_issues = None
|
||||
try:
|
||||
issues = gitea.list_issues(repo, state="open", limit=1, username=username)
|
||||
open_issues = issues.get("count", 0)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
**base,
|
||||
"available": True,
|
||||
"readme": readme,
|
||||
"docs_files": docs,
|
||||
"open_issues": open_issues,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
**base,
|
||||
"available": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def readme_seed_block(repo: str, readme: dict) -> str:
|
||||
path = readme.get("path") or "README.md"
|
||||
content = readme.get("content") or ""
|
||||
truncated_note = "\n\n*(README troncato — usa get_file per il testo completo)*" if readme.get("truncated") else ""
|
||||
return (
|
||||
f"<!-- seed:gitea {repo} {path} -->\n\n"
|
||||
f"## Sorgente Gitea: `{repo}`\n\n"
|
||||
f"Contenuto iniziale da `{path}`.\n\n"
|
||||
f"{content.rstrip()}{truncated_note}\n"
|
||||
)
|
||||
|
||||
|
||||
def seed_context_from_readme(username: str, repo: str, context_md: str) -> tuple[str, bool]:
|
||||
"""Importa README nel context se non già presente un seed Gitea."""
|
||||
if "<!-- seed:gitea " in context_md:
|
||||
return context_md, False
|
||||
readme = fetch_readme(username, repo)
|
||||
if not readme:
|
||||
return context_md, False
|
||||
block = readme_seed_block(repo, readme)
|
||||
if context_md.strip():
|
||||
return context_md.rstrip() + "\n\n---\n\n" + block, True
|
||||
return block, True
|
||||
@@ -1,406 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Filesystem-backed project context store."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from . import gitea_link
|
||||
from ..knowledge import gitea
|
||||
|
||||
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
|
||||
# Contesti condivisi: stessa struttura dei progetti personali, sotto un namespace comune.
|
||||
# Tutta la famiglia li legge e li aggiorna; archiviazione e ritorno a personale solo
|
||||
# per proprietario o admin.
|
||||
SHARED_NS = "_shared"
|
||||
SHARED_WITH_FAMILY = "family"
|
||||
_SHARED_META_KEYS = ("shared", "shared_with", "owner", "shared_at", "shared_by", "updated_by")
|
||||
|
||||
|
||||
class ConflictError(ValueError):
|
||||
"""Il contesto è stato modificato da qualcun altro dopo l'ultima lettura."""
|
||||
|
||||
# I tool MCP girano in parallelo nel threadpool: serializza le scritture per progetto
|
||||
_project_locks: dict[tuple[str, str], threading.Lock] = {}
|
||||
_project_locks_guard = threading.Lock()
|
||||
|
||||
|
||||
def _project_lock(username: str, project_id: str) -> threading.Lock:
|
||||
with _project_locks_guard:
|
||||
return _project_locks.setdefault((username, project_id), threading.Lock())
|
||||
|
||||
|
||||
def _context_root() -> str:
|
||||
return os.environ.get("MCP_CONTEXT_ROOT", "/data/context")
|
||||
|
||||
|
||||
def _user_root(username: str) -> str:
|
||||
path = os.path.join(_context_root(), username, "projects")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _shared_root() -> str:
|
||||
path = os.path.join(_context_root(), SHARED_NS, "projects")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _project_dir(username: str, project_id: str) -> str:
|
||||
if not SLUG_RE.match(project_id):
|
||||
raise ValueError("ID progetto non valido")
|
||||
return os.path.join(_user_root(username), project_id)
|
||||
|
||||
|
||||
def _id_taken(project_id: str, username: str, shared: bool) -> bool:
|
||||
"""Un ID condiviso non deve coincidere con progetti personali di nessun utente
|
||||
(altrimenti per quell'utente il personale nasconderebbe il condiviso)."""
|
||||
root = _context_root()
|
||||
if os.path.exists(os.path.join(_shared_root(), project_id)):
|
||||
return True
|
||||
users = [u for u in os.listdir(root) if u != SHARED_NS] if shared else [username]
|
||||
return any(os.path.exists(os.path.join(root, u, "projects", project_id)) for u in users)
|
||||
|
||||
|
||||
def _read_meta(meta_path: str) -> dict:
|
||||
with open(meta_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _is_member(meta: dict) -> bool:
|
||||
return meta.get("shared_with") == SHARED_WITH_FAMILY
|
||||
|
||||
|
||||
def _resolve_project(username: str, project_id: str) -> tuple[str, str]:
|
||||
"""(namespace, cartella) del progetto: prima quello personale, poi quello condiviso."""
|
||||
personal = _project_dir(username, project_id)
|
||||
if os.path.isfile(os.path.join(personal, "meta.json")):
|
||||
return username, personal
|
||||
shared = os.path.join(_shared_root(), project_id)
|
||||
meta_path = os.path.join(shared, "meta.json")
|
||||
if os.path.isfile(meta_path) and _is_member(_read_meta(meta_path)):
|
||||
return SHARED_NS, shared
|
||||
raise FileNotFoundError("Progetto non trovato")
|
||||
|
||||
|
||||
def _load_meta(username: str, project_id: str) -> tuple[str, dict]:
|
||||
_, proj_dir = _resolve_project(username, project_id)
|
||||
meta_path = os.path.join(proj_dir, "meta.json")
|
||||
return meta_path, _read_meta(meta_path)
|
||||
|
||||
|
||||
def _require_owner_or_admin(username: str, meta: dict, is_admin: bool, action: str) -> None:
|
||||
if meta.get("shared") and not is_admin and meta.get("owner") != username:
|
||||
raise PermissionError(
|
||||
f"Solo il proprietario ({meta.get('owner')}) o un admin può {action} un contesto condiviso"
|
||||
)
|
||||
|
||||
|
||||
def _save_meta(meta_path: str, meta: dict) -> None:
|
||||
meta["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _apply_gitea_link(
|
||||
username: str,
|
||||
meta: dict,
|
||||
gitea_repo: Optional[str],
|
||||
*,
|
||||
verify: bool = True,
|
||||
seed_from_gitea: bool = False,
|
||||
) -> dict:
|
||||
repo = gitea_link.normalize_gitea_repo(gitea_repo)
|
||||
if verify and repo:
|
||||
if not gitea.is_configured(username):
|
||||
raise RuntimeError(
|
||||
"Gitea non configurato per questo utente — impossibile collegare il repository"
|
||||
)
|
||||
gitea_link.verify_repo_access(username, repo)
|
||||
|
||||
if repo:
|
||||
meta["gitea_repo"] = repo
|
||||
else:
|
||||
meta.pop("gitea_repo", None)
|
||||
|
||||
if seed_from_gitea and repo:
|
||||
_, proj_dir = _resolve_project(username, meta["id"])
|
||||
ctx_path = os.path.join(proj_dir, "context.md")
|
||||
context_md = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
|
||||
new_md, changed = gitea_link.seed_context_from_readme(username, repo, context_md)
|
||||
if changed:
|
||||
with open(ctx_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_md)
|
||||
meta["gitea_seeded_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
return meta
|
||||
|
||||
|
||||
def list_projects(username: str, include_archived: bool = False) -> list:
|
||||
"""Progetti personali dell'utente seguiti dai contesti condivisi (campo shared=true)."""
|
||||
projects = []
|
||||
for root, shared in ((_user_root(username), False), (_shared_root(), True)):
|
||||
for name in sorted(os.listdir(root)):
|
||||
meta_path = os.path.join(root, name, "meta.json")
|
||||
if not os.path.isfile(meta_path):
|
||||
continue
|
||||
meta = _read_meta(meta_path)
|
||||
if shared and not _is_member(meta):
|
||||
continue
|
||||
if meta.get("archived") and not include_archived:
|
||||
continue
|
||||
projects.append(meta)
|
||||
return projects
|
||||
|
||||
|
||||
def create_project(
|
||||
username: str,
|
||||
title: str,
|
||||
tags: Optional[list[str]] = None,
|
||||
gitea_repo: Optional[str] = None,
|
||||
seed_from_gitea: bool = False,
|
||||
shared: bool = False,
|
||||
) -> dict:
|
||||
slug_base = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "progetto"
|
||||
project_id = slug_base[:40].strip("-")
|
||||
# ID univoco sia tra i progetti personali sia tra i condivisi (la risoluzione li unisce)
|
||||
while _id_taken(project_id, username, shared):
|
||||
project_id = f"{slug_base[:32].strip('-')}-{secrets.token_hex(2)}"
|
||||
proj_dir = os.path.join(_shared_root() if shared else _user_root(username), project_id)
|
||||
os.makedirs(os.path.join(proj_dir, "sessions"), exist_ok=True)
|
||||
os.makedirs(os.path.join(proj_dir, "artifacts"), exist_ok=True)
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
meta = {
|
||||
"id": project_id,
|
||||
"title": title.strip(),
|
||||
"tags": tags or [],
|
||||
"status": "active",
|
||||
"archived": False,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
if shared:
|
||||
meta.update(_shared_fields(username, now))
|
||||
repo = gitea_link.normalize_gitea_repo(gitea_repo)
|
||||
if repo:
|
||||
meta["gitea_repo"] = repo
|
||||
|
||||
with open(os.path.join(proj_dir, "meta.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
with open(os.path.join(proj_dir, "context.md"), "w", encoding="utf-8") as f:
|
||||
f.write(f"# {title.strip()}\n\n")
|
||||
|
||||
if repo:
|
||||
meta = _apply_gitea_link(
|
||||
username,
|
||||
meta,
|
||||
repo,
|
||||
verify=True,
|
||||
seed_from_gitea=seed_from_gitea,
|
||||
)
|
||||
_save_meta(os.path.join(proj_dir, "meta.json"), meta)
|
||||
|
||||
return meta
|
||||
|
||||
|
||||
def _shared_fields(username: str, now: str) -> dict:
|
||||
return {
|
||||
"shared": True,
|
||||
"shared_with": SHARED_WITH_FAMILY,
|
||||
"owner": username,
|
||||
"shared_by": username,
|
||||
"shared_at": now,
|
||||
}
|
||||
|
||||
|
||||
def share_project(username: str, project_id: str, shared: bool = True, is_admin: bool = False) -> dict:
|
||||
"""Sposta un progetto tra lo spazio personale del proprietario e quello condiviso.
|
||||
|
||||
La storia (context.md, sessions/, artifacts/) si sposta con il progetto.
|
||||
"""
|
||||
ns, proj_dir = _resolve_project(username, project_id)
|
||||
meta = _read_meta(os.path.join(proj_dir, "meta.json"))
|
||||
if shared:
|
||||
if ns == SHARED_NS:
|
||||
return meta
|
||||
target = os.path.join(_shared_root(), project_id)
|
||||
others = [
|
||||
u for u in os.listdir(_context_root())
|
||||
if u not in (SHARED_NS, username) and os.path.exists(os.path.join(_context_root(), u, "projects", project_id))
|
||||
]
|
||||
if others:
|
||||
raise ValueError(
|
||||
f"L'ID '{project_id}' è usato anche da un progetto personale di {', '.join(others)}: "
|
||||
"crea un contesto condiviso nuovo (create_project shared=true) e copiaci il contenuto"
|
||||
)
|
||||
else:
|
||||
if ns != SHARED_NS:
|
||||
return meta
|
||||
_require_owner_or_admin(username, meta, is_admin, "rendere personale")
|
||||
target = os.path.join(_user_root(meta["owner"]), project_id)
|
||||
if os.path.exists(target):
|
||||
raise ValueError(f"Esiste già un progetto '{project_id}' nella destinazione: rinomina prima")
|
||||
with _project_lock(ns, project_id):
|
||||
os.rename(proj_dir, target)
|
||||
meta_path = os.path.join(target, "meta.json")
|
||||
meta = _read_meta(meta_path)
|
||||
if shared:
|
||||
meta.update(_shared_fields(username, datetime.now().astimezone().isoformat(timespec="seconds")))
|
||||
else:
|
||||
for key in _SHARED_META_KEYS:
|
||||
meta.pop(key, None)
|
||||
_save_meta(meta_path, meta)
|
||||
return meta
|
||||
|
||||
|
||||
def link_project_repo(
|
||||
username: str,
|
||||
project_id: str,
|
||||
gitea_repo: Optional[str] = None,
|
||||
seed_from_gitea: bool = False,
|
||||
) -> dict:
|
||||
ns, _ = _resolve_project(username, project_id)
|
||||
with _project_lock(ns, project_id):
|
||||
meta_path, meta = _load_meta(username, project_id)
|
||||
meta = _apply_gitea_link(
|
||||
username,
|
||||
meta,
|
||||
gitea_repo,
|
||||
verify=bool(gitea_link.normalize_gitea_repo(gitea_repo)),
|
||||
seed_from_gitea=seed_from_gitea,
|
||||
)
|
||||
_save_meta(meta_path, meta)
|
||||
return meta
|
||||
|
||||
|
||||
def get_project_context(
|
||||
username: str,
|
||||
project_id: str,
|
||||
session_limit: int = 5,
|
||||
include_gitea: bool = True,
|
||||
) -> dict:
|
||||
_, proj_dir = _resolve_project(username, project_id)
|
||||
meta = _read_meta(os.path.join(proj_dir, "meta.json"))
|
||||
ctx_path = os.path.join(proj_dir, "context.md")
|
||||
context_md = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
|
||||
sessions_dir = os.path.join(proj_dir, "sessions")
|
||||
sessions = []
|
||||
if os.path.isdir(sessions_dir):
|
||||
files = sorted(os.listdir(sessions_dir), reverse=True)[:session_limit]
|
||||
for fname in files:
|
||||
path = os.path.join(sessions_dir, fname)
|
||||
if os.path.isfile(path):
|
||||
sessions.append({"name": fname, "content": open(path, encoding="utf-8").read()})
|
||||
|
||||
result = {"meta": meta, "context_md": context_md, "recent_sessions": sessions}
|
||||
gitea_repo = meta.get("gitea_repo")
|
||||
if include_gitea and gitea_repo:
|
||||
result["gitea"] = gitea_link.project_enrichment(username, gitea_repo)
|
||||
return result
|
||||
|
||||
|
||||
def save_context(
|
||||
username: str,
|
||||
project_id: str,
|
||||
content: str,
|
||||
mode: str = "append",
|
||||
if_revision: Optional[int] = None,
|
||||
) -> dict:
|
||||
"""if_revision: meta.revision letto prima di modificare; se nel frattempo qualcuno
|
||||
ha salvato, ConflictError invece di sovrascrivere (utile nei contesti condivisi)."""
|
||||
ns, proj_dir = _resolve_project(username, project_id)
|
||||
with _project_lock(ns, project_id):
|
||||
return _save_context_locked(username, proj_dir, content, mode, if_revision)
|
||||
|
||||
|
||||
def _save_context_locked(
|
||||
username: str, proj_dir: str, content: str, mode: str, if_revision: Optional[int]
|
||||
) -> dict:
|
||||
meta_path = os.path.join(proj_dir, "meta.json")
|
||||
if not os.path.isfile(meta_path):
|
||||
raise FileNotFoundError("Progetto non trovato")
|
||||
meta = _read_meta(meta_path)
|
||||
if if_revision is not None and int(meta.get("revision", 0)) != int(if_revision):
|
||||
raise ConflictError(
|
||||
f"Contesto modificato (revisione {meta.get('revision', 0)}) il {meta.get('updated_at')}"
|
||||
+ (f" da {meta['updated_by']}" if meta.get("updated_by") else "")
|
||||
+ ": rileggilo con get_project_context e riapplica la modifica"
|
||||
)
|
||||
ctx_path = os.path.join(proj_dir, "context.md")
|
||||
if mode == "replace":
|
||||
text = content
|
||||
else:
|
||||
existing = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
|
||||
text = existing.rstrip() + "\n\n" + content.strip() + "\n"
|
||||
with open(ctx_path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
meta["revision"] = int(meta.get("revision", 0)) + 1
|
||||
if meta.get("shared"):
|
||||
meta["updated_by"] = username
|
||||
_save_meta(meta_path, meta)
|
||||
# Nei condivisi il nome della sessione dice chi ha scritto
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + (f"-{username}" if meta.get("shared") else "")
|
||||
snapshot = stamp + ".md"
|
||||
n = 1
|
||||
while os.path.exists(os.path.join(proj_dir, "sessions", snapshot)):
|
||||
snapshot = f"{stamp}-{n}.md"
|
||||
n += 1
|
||||
with open(os.path.join(proj_dir, "sessions", snapshot), "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return meta
|
||||
|
||||
|
||||
def archive_project(
|
||||
username: str, project_id: str, archived: bool = True, is_admin: bool = False
|
||||
) -> dict:
|
||||
ns, _ = _resolve_project(username, project_id)
|
||||
with _project_lock(ns, project_id):
|
||||
meta_path, meta = _load_meta(username, project_id)
|
||||
_require_owner_or_admin(username, meta, is_admin, "archiviare")
|
||||
meta["archived"] = archived
|
||||
meta["status"] = "archived" if archived else "active"
|
||||
_save_meta(meta_path, meta)
|
||||
return meta
|
||||
|
||||
|
||||
def list_resources(username: str) -> list:
|
||||
resources = []
|
||||
for meta in list_projects(username, include_archived=False):
|
||||
ns = SHARED_NS if meta.get("shared") else username
|
||||
desc = f"Contesto {'condiviso con la famiglia' if meta.get('shared') else 'progetto'} {meta['id']}"
|
||||
if meta.get("gitea_repo"):
|
||||
desc += f" (Gitea: {meta['gitea_repo']})"
|
||||
resources.append(
|
||||
{
|
||||
"uri": f"loogle://context/{ns}/{meta['id']}",
|
||||
"name": meta["title"],
|
||||
"description": desc,
|
||||
"mimeType": "text/markdown",
|
||||
}
|
||||
)
|
||||
return resources
|
||||
|
||||
|
||||
def read_resource(username: str, uri: str) -> dict:
|
||||
for ns in (username, SHARED_NS):
|
||||
prefix = f"loogle://context/{ns}/"
|
||||
if uri.startswith(prefix):
|
||||
project_id = uri[len(prefix):]
|
||||
break
|
||||
else:
|
||||
raise FileNotFoundError("Risorsa non trovata")
|
||||
data = get_project_context(username, project_id, session_limit=0, include_gitea=False)
|
||||
text = data["context_md"]
|
||||
gitea_repo = data["meta"].get("gitea_repo")
|
||||
if gitea_repo:
|
||||
text = f"<!-- gitea_repo: {gitea_repo} -->\n\n" + text
|
||||
return {
|
||||
"uri": uri,
|
||||
"mimeType": "text/markdown",
|
||||
"text": text,
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SQLite schema for Loogle MCP Hub."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
DB_PATH = os.environ.get("MCP_DB", "/data/loogle_mcp.db")
|
||||
_local = threading.local()
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
client_name TEXT NOT NULL,
|
||||
redirect_uris TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
code_challenge TEXT,
|
||||
code_challenge_method TEXT,
|
||||
expires_at TEXT NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
client_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revoked_jtis (
|
||||
jti TEXT PRIMARY KEY,
|
||||
revoked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS indexed_documents (
|
||||
doc_id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
owner TEXT,
|
||||
visibility TEXT NOT NULL DEFAULT 'family',
|
||||
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS indexed_gitea_files (
|
||||
repo TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
sha TEXT,
|
||||
owner TEXT NOT NULL,
|
||||
visibility TEXT NOT NULL DEFAULT 'family',
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (repo, path)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gitea_indexed_repo ON indexed_gitea_files(repo);
|
||||
CREATE INDEX IF NOT EXISTS idx_gitea_indexed_at ON indexed_gitea_files(indexed_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS indexed_apps_records (
|
||||
source TEXT NOT NULL,
|
||||
record_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
owner TEXT NOT NULL DEFAULT 'family',
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (source, record_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_indexed_source ON indexed_apps_records(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_indexed_at ON indexed_apps_records(indexed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_at);
|
||||
"""
|
||||
|
||||
|
||||
def get_conn() -> sqlite3.Connection:
|
||||
conn = getattr(_local, "conn", None)
|
||||
if conn is None:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
_local.conn = conn
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
get_conn().executescript(SCHEMA)
|
||||
@@ -1,2 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Client REST verso app homelab LOOGLE."""
|
||||
@@ -1,75 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Client Loogle Casa — dashboard, meteo, rete."""
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from .session_client import SessionApiClient
|
||||
|
||||
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||
# Daniele MCP → admin su Loogle Casa
|
||||
MCP_TO_SERVICE_USER = {
|
||||
"daniele": "admin",
|
||||
"lucia": "lucia",
|
||||
"davide": "davide",
|
||||
"luca": "luca",
|
||||
}
|
||||
|
||||
|
||||
def _service_username(mcp_username: str) -> str:
|
||||
return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower())
|
||||
PUBLIC_URL = os.environ.get("LOOGLE_CASA_URL", "https://casa.loogle.it").rstrip("/")
|
||||
API_URL = os.environ.get("LOOGLE_CASA_API_URL", PUBLIC_URL).rstrip("/")
|
||||
|
||||
_client: Optional[SessionApiClient] = None
|
||||
|
||||
|
||||
def _client_instance() -> SessionApiClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
verify = os.environ.get("LOOGLE_CASA_VERIFY_SSL", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
client = SessionApiClient(
|
||||
service="LOOGLE_CASA",
|
||||
base_url=API_URL,
|
||||
verify_ssl=verify,
|
||||
)
|
||||
client.map_username = _service_username # type: ignore[attr-defined]
|
||||
_client = client
|
||||
return _client
|
||||
|
||||
|
||||
def is_configured(username: Optional[str] = None) -> bool:
|
||||
user = (username or "daniele").lower()
|
||||
if user in MCP_USERS:
|
||||
key = f"LOOGLE_CASA_PASSWORD_{user.upper()}"
|
||||
if os.environ.get(key, "").strip():
|
||||
return True
|
||||
if os.environ.get("LOOGLE_CASA_PASSWORD", "").strip():
|
||||
return True
|
||||
return user in MCP_USERS
|
||||
|
||||
|
||||
def get_dashboard(username: str) -> dict:
|
||||
return _client_instance().get("/api/dashboard", username=username)
|
||||
|
||||
|
||||
def get_weather_home(username: str) -> dict:
|
||||
return _client_instance().get("/api/weather/home", username=username)
|
||||
|
||||
|
||||
def get_network_overview(username: str) -> dict:
|
||||
return _client_instance().get("/api/network/overview", username=username)
|
||||
|
||||
|
||||
def get_network_failover_status(username: str) -> dict:
|
||||
return _client_instance().get("/api/network/failover/status", username=username)
|
||||
|
||||
|
||||
def get_network_mcp_status(username: str) -> dict:
|
||||
return _client_instance().get("/api/network/mcp", username=username)
|
||||
|
||||
|
||||
def get_alerts_cards(username: str) -> Any:
|
||||
return _client_instance().get("/api/alerts/cards", username=username)
|
||||
@@ -1,71 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Client Home Assistant REST API (read-only)."""
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
PUBLIC_URL = os.environ.get("HA_URL", "https://ha.loogle.it").rstrip("/")
|
||||
API_URL = os.environ.get("HA_API_URL", PUBLIC_URL).rstrip("/")
|
||||
HA_TOKEN = os.environ.get("HA_TOKEN", "").strip()
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
return bool(HA_TOKEN)
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
if not HA_TOKEN:
|
||||
raise RuntimeError(
|
||||
"HA_TOKEN non configurato in .env — crea un long-lived token in Home Assistant"
|
||||
)
|
||||
return {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _request(method: str, path: str, *, params: Optional[dict] = None) -> Any:
|
||||
verify = os.environ.get("HA_VERIFY_SSL", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
url = path if path.startswith("http") else urljoin(API_URL + "/", path.lstrip("/"))
|
||||
with httpx.Client(timeout=httpx.Timeout(30.0, connect=5.0), verify=verify) as client:
|
||||
resp = client.request(method, url, headers=_headers(), params=params)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"Home Assistant {path}: HTTP {resp.status_code} {resp.text[:200]}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
def get_config() -> dict:
|
||||
return _request("GET", "/api/config")
|
||||
|
||||
|
||||
def get_entity(entity_id: str) -> dict:
|
||||
return _request("GET", f"/api/states/{entity_id}")
|
||||
|
||||
|
||||
def list_entities(domain: Optional[str] = None, limit: int = 100) -> list:
|
||||
states = _request("GET", "/api/states")
|
||||
if domain:
|
||||
prefix = domain if domain.endswith(".") else f"{domain}."
|
||||
states = [s for s in states if s.get("entity_id", "").startswith(prefix)]
|
||||
return states[:limit]
|
||||
|
||||
|
||||
def search_entities(query: str, limit: int = 30) -> list:
|
||||
q = query.lower()
|
||||
matches = []
|
||||
for state in _request("GET", "/api/states"):
|
||||
eid = state.get("entity_id", "")
|
||||
name = (state.get("attributes") or {}).get("friendly_name", "")
|
||||
blob = f"{eid} {name}".lower()
|
||||
if q in blob:
|
||||
matches.append({
|
||||
"entity_id": eid,
|
||||
"state": state.get("state"),
|
||||
"friendly_name": name,
|
||||
"last_changed": state.get("last_changed"),
|
||||
})
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
return matches
|
||||
@@ -1,78 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Client Irrigazione Smart — irri.loogle.it."""
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from .session_client import SessionApiClient
|
||||
|
||||
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||
MCP_TO_SERVICE_USER = {
|
||||
"daniele": "admin",
|
||||
"lucia": "lucia",
|
||||
"davide": "dado",
|
||||
"luca": "luca",
|
||||
}
|
||||
|
||||
PUBLIC_URL = os.environ.get("IRRIGAZIONE_URL", "https://irri.loogle.it").rstrip("/")
|
||||
API_URL = os.environ.get("IRRIGAZIONE_API_URL", PUBLIC_URL).rstrip("/")
|
||||
|
||||
_client: Optional[SessionApiClient] = None
|
||||
|
||||
|
||||
def _service_username(mcp_username: str) -> str:
|
||||
return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower())
|
||||
|
||||
|
||||
def _client_instance() -> SessionApiClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
verify = os.environ.get("IRRIGAZIONE_VERIFY_SSL", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
client = SessionApiClient(
|
||||
service="IRRIGAZIONE",
|
||||
base_url=API_URL,
|
||||
verify_ssl=verify,
|
||||
)
|
||||
client.map_username = _service_username # type: ignore[attr-defined]
|
||||
_client = client
|
||||
return _client
|
||||
|
||||
|
||||
def is_configured(username: Optional[str] = None) -> bool:
|
||||
user = (username or "daniele").lower()
|
||||
if os.environ.get(f"IRRIGAZIONE_PASSWORD_{user.upper()}", "").strip():
|
||||
return True
|
||||
if os.environ.get("IRRIGAZIONE_PASSWORD", "").strip():
|
||||
return True
|
||||
return user in MCP_USERS
|
||||
|
||||
|
||||
def get_status(username: str) -> dict:
|
||||
return _client_instance().get("/api/status", username=username)
|
||||
|
||||
|
||||
def get_zones(username: str) -> Any:
|
||||
return _client_instance().get("/api/zones", username=username)
|
||||
|
||||
|
||||
def get_history(username: str, limit: int = 30) -> Any:
|
||||
data = _client_instance().get("/api/history", username=username)
|
||||
if isinstance(data, list):
|
||||
return data[:limit]
|
||||
if isinstance(data, dict) and "items" in data:
|
||||
items = data["items"]
|
||||
return items[:limit] if isinstance(items, list) else data
|
||||
return data
|
||||
|
||||
|
||||
def get_events(username: str, limit: int = 50) -> Any:
|
||||
data = _client_instance().get("/api/events", username=username)
|
||||
if isinstance(data, list):
|
||||
return data[:limit]
|
||||
return data
|
||||
|
||||
|
||||
def get_lavori_summary(username: str) -> Any:
|
||||
return _client_instance().get("/api/lavori/summary", username=username)
|
||||
@@ -1,122 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Client HTTP con sessione cookie (Loogle Casa, Irrigazione)."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.session_client")
|
||||
|
||||
_sessions: dict[str, tuple[str, float]] = {}
|
||||
_sessions_lock = threading.Lock()
|
||||
SESSION_TTL = 3600 * 12
|
||||
|
||||
|
||||
class SessionApiClient:
|
||||
"""Login cookie-based con cache per utente MCP."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
service: str,
|
||||
base_url: str,
|
||||
login_path: str = "/api/login",
|
||||
verify_ssl: bool = True,
|
||||
) -> None:
|
||||
self.service = service
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.login_path = login_path
|
||||
self.verify_ssl = verify_ssl
|
||||
|
||||
def _password_for_user(self, username: str) -> Optional[str]:
|
||||
import os
|
||||
user = username.lower()
|
||||
env_key = f"{self.service}_PASSWORD_{user.upper()}"
|
||||
pwd = os.environ.get(env_key, "").strip()
|
||||
if pwd:
|
||||
return pwd
|
||||
fallback = os.environ.get(f"{self.service}_PASSWORD", "").strip()
|
||||
if fallback:
|
||||
return fallback
|
||||
return user
|
||||
|
||||
def _cache_key(self, username: str) -> str:
|
||||
return f"{self.service}:{username.lower()}"
|
||||
|
||||
def _get_cached_cookie(self, username: str) -> Optional[str]:
|
||||
key = self._cache_key(username)
|
||||
with _sessions_lock:
|
||||
row = _sessions.get(key)
|
||||
if not row:
|
||||
return None
|
||||
cookie, expires = row
|
||||
if time.time() > expires:
|
||||
_sessions.pop(key, None)
|
||||
return None
|
||||
return cookie
|
||||
|
||||
def _store_cookie(self, username: str, cookie: str) -> None:
|
||||
key = self._cache_key(username)
|
||||
with _sessions_lock:
|
||||
_sessions[key] = (cookie, time.time() + SESSION_TTL)
|
||||
|
||||
def login(self, username: str) -> str:
|
||||
cached = self._get_cached_cookie(username)
|
||||
if cached:
|
||||
return cached
|
||||
service_user = username
|
||||
if hasattr(self, "map_username"):
|
||||
service_user = self.map_username(username) # type: ignore[attr-defined]
|
||||
password = self._password_for_user(username)
|
||||
if not password:
|
||||
raise RuntimeError(
|
||||
f"Password {self.service} non configurata per {username}. "
|
||||
f"Imposta {self.service}_PASSWORD_{username.upper()} in .env"
|
||||
)
|
||||
url = urljoin(self.base_url + "/", self.login_path.lstrip("/"))
|
||||
with httpx.Client(timeout=httpx.Timeout(30.0, connect=5.0), verify=self.verify_ssl) as client:
|
||||
resp = client.post(
|
||||
url, json={"username": service_user, "password": password},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"Login {self.service} fallito per {username}: HTTP {resp.status_code}"
|
||||
)
|
||||
cookie = resp.cookies.get("session")
|
||||
if not cookie:
|
||||
raise RuntimeError(f"Login {self.service}: cookie session mancante")
|
||||
self._store_cookie(username, cookie)
|
||||
return cookie
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
username: str,
|
||||
params: Optional[dict] = None,
|
||||
json_body: Optional[dict] = None,
|
||||
) -> Any:
|
||||
cookie = self.login(username)
|
||||
url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/"))
|
||||
headers = {"Cookie": f"session={cookie}"}
|
||||
with httpx.Client(timeout=httpx.Timeout(60.0, connect=5.0), verify=self.verify_ssl) as client:
|
||||
resp = client.request(method, url, headers=headers, params=params, json=json_body)
|
||||
if resp.status_code == 401:
|
||||
with _sessions_lock:
|
||||
_sessions.pop(self._cache_key(username), None)
|
||||
cookie = self.login(username)
|
||||
headers = {"Cookie": f"session={cookie}"}
|
||||
resp = client.request(method, url, headers=headers, params=params, json=json_body)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"{self.service} {method} {path}: HTTP {resp.status_code} {resp.text[:200]}")
|
||||
if resp.headers.get("content-type", "").startswith("application/json"):
|
||||
return resp.json()
|
||||
return resp.text
|
||||
|
||||
def get(self, path: str, *, username: str, params: Optional[dict] = None) -> Any:
|
||||
return self.request("GET", path, username=username, params=params)
|
||||
@@ -1,208 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Client Turni-Live — turni.loogle.it (JWT Bearer)."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.turni")
|
||||
|
||||
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||
MCP_TO_SERVICE_USER = {
|
||||
"daniele": "daniely",
|
||||
"lucia": "lucia",
|
||||
"davide": "davide",
|
||||
"luca": "luca",
|
||||
}
|
||||
|
||||
PUBLIC_URL = os.environ.get("TURNI_URL", "https://turni.loogle.it").rstrip("/")
|
||||
API_URL = os.environ.get("TURNI_API_URL", PUBLIC_URL).rstrip("/")
|
||||
|
||||
_jwt_cache: dict[str, tuple[str, float]] = {}
|
||||
_jwt_lock = threading.Lock()
|
||||
JWT_TTL = 3600 * 6
|
||||
|
||||
|
||||
def _service_username(mcp_username: str) -> str:
|
||||
return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower())
|
||||
|
||||
|
||||
def _password_for_user(mcp_username: str) -> Optional[str]:
|
||||
user = mcp_username.lower()
|
||||
pwd = os.environ.get(f"TURNI_PASSWORD_{user.upper()}", "").strip()
|
||||
if pwd:
|
||||
return pwd
|
||||
return os.environ.get("TURNI_PASSWORD", "").strip() or None
|
||||
|
||||
|
||||
def _jwt_for_user(mcp_username: str) -> Optional[str]:
|
||||
user = mcp_username.lower()
|
||||
direct = os.environ.get(f"TURNI_JWT_{user.upper()}", "").strip()
|
||||
if direct:
|
||||
return direct
|
||||
return os.environ.get("TURNI_JWT", "").strip() or None
|
||||
|
||||
|
||||
def is_configured(username: Optional[str] = None) -> bool:
|
||||
user = (username or "daniele").lower()
|
||||
if _jwt_for_user(user):
|
||||
return True
|
||||
if _password_for_user(user):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _store_jwt(mcp_username: str, token: str) -> None:
|
||||
with _jwt_lock:
|
||||
_jwt_cache[mcp_username.lower()] = (token, time.time() + JWT_TTL)
|
||||
|
||||
|
||||
def _cached_jwt(mcp_username: str) -> Optional[str]:
|
||||
with _jwt_lock:
|
||||
row = _jwt_cache.get(mcp_username.lower())
|
||||
if not row:
|
||||
return None
|
||||
token, expires = row
|
||||
if time.time() > expires:
|
||||
_jwt_cache.pop(mcp_username.lower(), None)
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def login(mcp_username: str) -> str:
|
||||
cached = _cached_jwt(mcp_username)
|
||||
if cached:
|
||||
return cached
|
||||
preset = _jwt_for_user(mcp_username)
|
||||
if preset:
|
||||
_store_jwt(mcp_username, preset)
|
||||
return preset
|
||||
password = _password_for_user(mcp_username)
|
||||
if not password:
|
||||
raise RuntimeError(
|
||||
f"Turni non configurato per {mcp_username}. "
|
||||
f"Imposta TURNI_PASSWORD_{mcp_username.upper()} o TURNI_JWT_{mcp_username.upper()}"
|
||||
)
|
||||
service_user = _service_username(mcp_username)
|
||||
verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
url = urljoin(API_URL + "/", "api/auth/login")
|
||||
with httpx.Client(timeout=httpx.Timeout(30.0, connect=5.0), verify=verify) as client:
|
||||
resp = client.post(url, json={"username": service_user, "password": password})
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"Login Turni fallito: HTTP {resp.status_code}")
|
||||
data = resp.json()
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
raise RuntimeError("Login Turni: token JWT mancante")
|
||||
_store_jwt(mcp_username, token)
|
||||
return token
|
||||
|
||||
|
||||
def _request(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
mcp_username: str,
|
||||
params: Optional[dict] = None,
|
||||
) -> Any:
|
||||
token = login(mcp_username)
|
||||
verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
url = path if path.startswith("http") else urljoin(API_URL + "/", path.lstrip("/"))
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
with httpx.Client(timeout=httpx.Timeout(60.0, connect=5.0), verify=verify) as client:
|
||||
resp = client.request(method, url, headers=headers, params=params)
|
||||
if resp.status_code == 401:
|
||||
with _jwt_lock:
|
||||
_jwt_cache.pop(mcp_username.lower(), None)
|
||||
headers["Authorization"] = f"Bearer {login(mcp_username)}"
|
||||
resp = client.request(method, url, headers=headers, params=params)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"Turni {path}: HTTP {resp.status_code} {resp.text[:200]}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
def get_status() -> dict:
|
||||
verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
url = urljoin(API_URL + "/", "api/status")
|
||||
with httpx.Client(timeout=httpx.Timeout(30.0, connect=5.0), verify=verify) as client:
|
||||
resp = client.get(url)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def list_doctors(mcp_username: str) -> Any:
|
||||
return _request("GET", "/api/doctors", mcp_username=mcp_username)
|
||||
|
||||
|
||||
def get_shift_assignments(
|
||||
mcp_username: str,
|
||||
*,
|
||||
from_date: Optional[str] = None,
|
||||
to_date: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
) -> Any:
|
||||
params: dict = {}
|
||||
if from_date:
|
||||
params["from"] = from_date
|
||||
if to_date:
|
||||
params["to"] = to_date
|
||||
data = _request("GET", "/api/shift-assignments", mcp_username=mcp_username, params=params or None)
|
||||
if isinstance(data, list):
|
||||
return data[:limit]
|
||||
if isinstance(data, dict):
|
||||
items = data.get("assignments") or data.get("items") or data.get("results")
|
||||
if isinstance(items, list):
|
||||
return items[:limit]
|
||||
return data
|
||||
|
||||
|
||||
def get_my_shifts(
|
||||
mcp_username: str,
|
||||
*,
|
||||
from_date: Optional[str] = None,
|
||||
to_date: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
) -> dict:
|
||||
"""Turni dell'utente MCP: filtra per doctorId collegato o per nome medico."""
|
||||
user_info = _request("GET", "/api/users/me", mcp_username=mcp_username)
|
||||
doctor_id = user_info.get("doctorId")
|
||||
assignments = get_shift_assignments(
|
||||
mcp_username, from_date=from_date, to_date=to_date, limit=500,
|
||||
)
|
||||
if not isinstance(assignments, list):
|
||||
return {"user": user_info, "assignments": assignments}
|
||||
if doctor_id:
|
||||
mine = [a for a in assignments if a.get("doctorId") == doctor_id or a.get("doctor_id") == doctor_id]
|
||||
else:
|
||||
service_user = _service_username(mcp_username)
|
||||
doctors = list_doctors(mcp_username)
|
||||
doc_ids = set()
|
||||
if isinstance(doctors, list):
|
||||
for doc in doctors:
|
||||
name = (doc.get("name") or doc.get("fullName") or "").lower()
|
||||
if service_user.lower() in name or mcp_username.lower() in name:
|
||||
doc_ids.add(doc.get("id") or doc.get("doctorId"))
|
||||
mine = [
|
||||
a for a in assignments
|
||||
if (a.get("doctorId") or a.get("doctor_id")) in doc_ids
|
||||
] if doc_ids else assignments[:limit]
|
||||
return {
|
||||
"user": {
|
||||
"username": user_info.get("username"),
|
||||
"role": user_info.get("role"),
|
||||
"doctorId": doctor_id,
|
||||
},
|
||||
"assignments": mine[:limit],
|
||||
"count": len(mine),
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""JWT utilities for OAuth access tokens."""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import secrets
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
|
||||
from .db import get_conn
|
||||
|
||||
JWT_ALG = "HS256"
|
||||
ACCESS_TOKEN_HOURS = 1
|
||||
REFRESH_TOKEN_DAYS = 30
|
||||
|
||||
|
||||
def jwt_secret() -> str:
|
||||
secret = os.environ.get("MCP_JWT_SECRET", "").strip()
|
||||
if not secret:
|
||||
secret = secrets.token_urlsafe(48)
|
||||
os.environ["MCP_JWT_SECRET"] = secret
|
||||
return secret
|
||||
|
||||
|
||||
def base_url() -> str:
|
||||
return os.environ.get("MCP_BASE_URL", "https://mcp.loogle.it").rstrip("/")
|
||||
|
||||
|
||||
def scopes_for_user(user: dict, requested: str) -> str:
|
||||
parts = [s for s in requested.split() if s]
|
||||
allowed = {
|
||||
"context:read",
|
||||
"context:write",
|
||||
"knowledge:read",
|
||||
"knowledge:write",
|
||||
"gitea:read",
|
||||
"gitea:write",
|
||||
"home:read",
|
||||
"irrigation:read",
|
||||
"turni:read",
|
||||
}
|
||||
if user.get("is_admin"):
|
||||
allowed.add("admin")
|
||||
filtered = [s for s in parts if s in allowed]
|
||||
if not filtered:
|
||||
filtered = [
|
||||
"context:read",
|
||||
"context:write",
|
||||
"knowledge:read",
|
||||
"knowledge:write",
|
||||
"gitea:read",
|
||||
"gitea:write",
|
||||
"home:read",
|
||||
"irrigation:read",
|
||||
"turni:read",
|
||||
]
|
||||
return " ".join(filtered)
|
||||
|
||||
|
||||
def create_access_token(user: dict, scope: str, client_id: str) -> tuple[str, str]:
|
||||
jti = str(uuid.uuid4())
|
||||
now = datetime.datetime.utcnow()
|
||||
payload = {
|
||||
"iss": base_url(),
|
||||
"sub": user["username"],
|
||||
"uid": user["id"],
|
||||
"scope": scope,
|
||||
"client_id": client_id,
|
||||
"jti": jti,
|
||||
"iat": now,
|
||||
"exp": now + datetime.timedelta(hours=ACCESS_TOKEN_HOURS),
|
||||
}
|
||||
token = jwt.encode(payload, jwt_secret(), algorithm=JWT_ALG)
|
||||
return token, jti
|
||||
|
||||
|
||||
def create_refresh_token(user_id: int, scope: str, client_id: str) -> str:
|
||||
token = secrets.token_urlsafe(48)
|
||||
expires = (
|
||||
datetime.datetime.utcnow() + datetime.timedelta(days=REFRESH_TOKEN_DAYS)
|
||||
).strftime("%Y-%m-%d %H:%M:%S")
|
||||
get_conn().execute(
|
||||
"INSERT INTO refresh_tokens(token,user_id,client_id,scope,expires_at) VALUES (?,?,?,?,?)",
|
||||
(token, user_id, client_id, scope, expires),
|
||||
)
|
||||
get_conn().commit()
|
||||
return token
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, jwt_secret(), algorithms=[JWT_ALG], issuer=base_url())
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
jti = payload.get("jti")
|
||||
if not jti:
|
||||
return None
|
||||
row = get_conn().execute(
|
||||
"SELECT 1 FROM revoked_jtis WHERE jti=? AND expires_at > datetime('now')",
|
||||
(jti,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def revoke_jti(jti: str, expires_at: datetime.datetime) -> None:
|
||||
get_conn().execute(
|
||||
"INSERT OR IGNORE INTO revoked_jtis(jti,expires_at) VALUES (?,?)",
|
||||
(jti, expires_at.strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
get_conn().commit()
|
||||
|
||||
|
||||
def revoke_refresh_token(token: str) -> None:
|
||||
get_conn().execute("UPDATE refresh_tokens SET revoked=1 WHERE token=?", (token,))
|
||||
get_conn().commit()
|
||||
|
||||
|
||||
def consume_refresh_token(token: str) -> Optional[dict]:
|
||||
row = get_conn().execute(
|
||||
"SELECT * FROM refresh_tokens WHERE token=? AND revoked=0 AND expires_at > datetime('now')",
|
||||
(token,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
def has_scope(claims: dict, scope: str) -> bool:
|
||||
scopes = set((claims.get("scope") or "").split())
|
||||
if "admin" in scopes:
|
||||
return True
|
||||
return scope in scopes
|
||||
Whitespace-only changes.
@@ -1,295 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""RAG su export Irrigazione + Turni (P7)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from ..db import get_conn
|
||||
from ..integrations import irrigazione, turni
|
||||
from . import embeddings, qdrant_store
|
||||
from .text_chunk import chunk_text as _chunk_text
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.apps_indexer")
|
||||
|
||||
INDEX_USER = os.environ.get("APPS_INDEX_USER", "daniele").lower()
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
flag = os.environ.get("APPS_INDEX_ENABLED", "yes").strip().lower()
|
||||
return flag not in ("0", "false", "no", "off")
|
||||
|
||||
|
||||
def _doc_id(source: str, record_id: str) -> int:
|
||||
key = f"{source}:{record_id}"
|
||||
return abs(hash(key)) % (2**31 - 1)
|
||||
|
||||
|
||||
def _index_text(source: str, record_id: str, title: str, text: str) -> dict:
|
||||
chunks = _chunk_text(_truncate(text))
|
||||
if len(chunks) > 8:
|
||||
chunks = chunks[:8]
|
||||
if not chunks:
|
||||
return {"source": source, "record_id": record_id, "chunks": 0, "skipped": True}
|
||||
vectors = embeddings.embed_texts(chunks)
|
||||
collection = qdrant_store.APPS_SHARED_COLLECTION
|
||||
doc_id = _doc_id(source, record_id)
|
||||
qdrant_store.delete_by_doc(collection, doc_id)
|
||||
ids = []
|
||||
payloads = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
point_id = f"app-{source}-{record_id}-chunk-{i}"
|
||||
ids.append(point_id)
|
||||
payloads.append(
|
||||
{
|
||||
"doc_id": doc_id,
|
||||
"source": source,
|
||||
"record_id": record_id,
|
||||
"chunk_index": i,
|
||||
"title": title,
|
||||
"text": chunk,
|
||||
"owner": "family",
|
||||
"visibility": "family",
|
||||
}
|
||||
)
|
||||
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
|
||||
get_conn().execute(
|
||||
"INSERT INTO indexed_apps_records(source,record_id,title,owner,chunk_count,indexed_at)"
|
||||
" VALUES (?,?,?,?,?,datetime('now'))"
|
||||
" ON CONFLICT(source,record_id) DO UPDATE SET"
|
||||
" title=excluded.title, chunk_count=excluded.chunk_count, indexed_at=datetime('now')",
|
||||
(source, record_id, title, "family", len(chunks)),
|
||||
)
|
||||
get_conn().commit()
|
||||
return {"source": source, "record_id": record_id, "chunks": len(chunks), "collection": collection}
|
||||
|
||||
|
||||
def _format_irrigation_history_item(item: dict, idx: int) -> str:
|
||||
parts = [f"Irrigazione storico #{idx}"]
|
||||
for key in ("started_at", "ended_at", "zone", "zone_name", "duration_min", "volume_l", "mode", "note"):
|
||||
if item.get(key) is not None:
|
||||
parts.append(f"{key}: {item[key]}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _format_irrigation_event(item: dict, idx: int) -> str:
|
||||
parts = [f"Irrigazione evento #{idx}"]
|
||||
for key in ("ts", "time", "type", "level", "message", "zone", "detail"):
|
||||
if item.get(key) is not None:
|
||||
parts.append(f"{key}: {item[key]}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _format_turni_assignment(item: dict, idx: int) -> str:
|
||||
parts = [f"Turno #{idx}"]
|
||||
for key in (
|
||||
"date", "startDate", "endDate", "doctorId", "doctorName", "doctor_name",
|
||||
"slotId", "slotName", "slot_name", "uoc", "uocName", "shiftType", "notes",
|
||||
):
|
||||
if item.get(key) is not None:
|
||||
parts.append(f"{key}: {item[key]}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = 6000) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 20] + "\n… [truncated]"
|
||||
|
||||
|
||||
def _summarize_irrigation_status(status: dict) -> str:
|
||||
lines = ["Irrigazione — snapshot stato"]
|
||||
for key in ("plan_mode", "program", "simulation", "hibernation", "draining"):
|
||||
if key in status:
|
||||
lines.append(f"{key}: {status[key]}")
|
||||
ha = status.get("ha") or {}
|
||||
lines.append(f"ha_connected: {ha.get('connected')}")
|
||||
zones = status.get("zones") or []
|
||||
lines.append(f"zone_count: {len(zones)}")
|
||||
for z in zones[:12]:
|
||||
if isinstance(z, dict):
|
||||
lines.append(
|
||||
f" - {z.get('name', z.get('id'))}: state={z.get('ha_state')} excluded={z.get('excluded')}"
|
||||
)
|
||||
analysis = status.get("analysis")
|
||||
if isinstance(analysis, dict):
|
||||
for k, v in list(analysis.items())[:8]:
|
||||
lines.append(f"analysis.{k}: {v}")
|
||||
elif analysis:
|
||||
lines.append(f"analysis: {analysis}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _summarize_zones(zones: Any) -> str:
|
||||
items = zones if isinstance(zones, list) else _normalize_list(zones)
|
||||
lines = [f"Irrigazione — zone ({len(items)})"]
|
||||
for z in items[:20]:
|
||||
if not isinstance(z, dict):
|
||||
continue
|
||||
lines.append(
|
||||
f"- {z.get('name', z.get('id'))}: ha={z.get('ha_state')} "
|
||||
f"rate_mmh={z.get('rate_mmh')} flow={z.get('zone_flow_lph')}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _normalize_list(data: Any) -> list:
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
for key in ("items", "results", "history", "events", "assignments", "records"):
|
||||
val = data.get(key)
|
||||
if isinstance(val, list):
|
||||
return val
|
||||
return []
|
||||
|
||||
|
||||
def index_irrigazione(*, username: Optional[str] = None, history_limit: Optional[int] = None, events_limit: Optional[int] = None) -> dict:
|
||||
user = username or INDEX_USER
|
||||
history_limit = history_limit if history_limit is not None else _int_env("APPS_INDEX_HISTORY_LIMIT", 25)
|
||||
events_limit = events_limit if events_limit is not None else _int_env("APPS_INDEX_EVENTS_LIMIT", 40)
|
||||
if not irrigazione.is_configured(user):
|
||||
return {"source": "irrigazione", "skipped": True, "reason": "not configured"}
|
||||
indexed = 0
|
||||
errors = 0
|
||||
try:
|
||||
status = irrigazione.get_status(user)
|
||||
status_text = _summarize_irrigation_status(status)
|
||||
_index_text("irrigazione", "status-snapshot", "Irrigazione — stato attuale", status_text)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Irrigazione status index failed: %s", exc)
|
||||
errors += 1
|
||||
try:
|
||||
zones = irrigazione.get_zones(user)
|
||||
zones_text = _summarize_zones(zones)
|
||||
_index_text("irrigazione", "zones-snapshot", "Irrigazione — zone", zones_text)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Irrigazione zones index failed: %s", exc)
|
||||
errors += 1
|
||||
try:
|
||||
history = irrigazione.get_history(user, limit=history_limit)
|
||||
for i, item in enumerate(_normalize_list(history)):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rid = str(item.get("id") or item.get("started_at") or i)
|
||||
text = _format_irrigation_history_item(item, i)
|
||||
_index_text("irrigazione", f"history-{rid}", f"Irrigazione storico {rid}", text)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Irrigazione history index failed: %s", exc)
|
||||
errors += 1
|
||||
try:
|
||||
events = irrigazione.get_events(user, limit=events_limit)
|
||||
for i, item in enumerate(_normalize_list(events)):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rid = str(item.get("id") or item.get("ts") or item.get("time") or i)
|
||||
text = _format_irrigation_event(item, i)
|
||||
_index_text("irrigazione", f"event-{rid}", f"Irrigazione evento {rid}", text)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Irrigazione events index failed: %s", exc)
|
||||
errors += 1
|
||||
try:
|
||||
lavori = irrigazione.get_lavori_summary(user)
|
||||
lavori_text = _truncate(json.dumps(lavori, ensure_ascii=False, indent=2), 4000)
|
||||
_index_text("irrigazione", "lavori-summary", "Irrigazione — lavori manutenzione", lavori_text)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Irrigazione lavori index failed: %s", exc)
|
||||
errors += 1
|
||||
return {"source": "irrigazione", "indexed": indexed, "errors": errors}
|
||||
|
||||
|
||||
def index_turni(*, username: Optional[str] = None, assignments_limit: Optional[int] = None) -> dict:
|
||||
user = username or INDEX_USER
|
||||
assignments_limit = assignments_limit if assignments_limit is not None else _int_env("APPS_INDEX_ASSIGNMENTS_LIMIT", 80)
|
||||
if not turni.is_configured(user):
|
||||
return {"source": "turni", "skipped": True, "reason": "not configured"}
|
||||
indexed = 0
|
||||
errors = 0
|
||||
try:
|
||||
status = turni.get_status()
|
||||
status_text = _truncate(json.dumps(status, ensure_ascii=False, indent=2), 2000)
|
||||
_index_text("turni", "status-snapshot", "Turni — stato servizio", status_text)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Turni status index failed: %s", exc)
|
||||
errors += 1
|
||||
try:
|
||||
doctors = turni.list_doctors(user)
|
||||
lines = ["Turni — medici"]
|
||||
for d in (_normalize_list(doctors) if not isinstance(doctors, list) else doctors)[:40]:
|
||||
if isinstance(d, dict):
|
||||
lines.append(f"- {d.get('name', d.get('fullName'))} id={d.get('id')}")
|
||||
_index_text("turni", "doctors-list", "Turni — elenco medici", "\n".join(lines))
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Turni doctors index failed: %s", exc)
|
||||
errors += 1
|
||||
try:
|
||||
assignments = turni.get_shift_assignments(user, limit=assignments_limit)
|
||||
for i, item in enumerate(_normalize_list(assignments)):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rid = str(item.get("id") or item.get("date") or i)
|
||||
text = _format_turni_assignment(item, i)
|
||||
_index_text("turni", f"assignment-{rid}", f"Turno {rid}", text)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Turni assignments index failed: %s", exc)
|
||||
errors += 1
|
||||
return {"source": "turni", "indexed": indexed, "errors": errors}
|
||||
|
||||
|
||||
def index_all(*, username: Optional[str] = None) -> dict:
|
||||
if not _enabled():
|
||||
return {"skipped": True, "reason": "APPS_INDEX_ENABLED=no"}
|
||||
user = username or INDEX_USER
|
||||
return {
|
||||
"irrigazione": index_irrigazione(username=user),
|
||||
"turni": index_turni(username=user),
|
||||
"at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def search_apps_knowledge(query: str, limit: int = 8, source: Optional[str] = None) -> list:
|
||||
vectors = [embeddings.embed_query(query)]
|
||||
flt = {"source": source} if source else None
|
||||
hits = qdrant_store.search(
|
||||
[qdrant_store.APPS_SHARED_COLLECTION],
|
||||
vectors[0],
|
||||
limit=limit,
|
||||
visibility_filter=flt,
|
||||
)
|
||||
for hit in hits:
|
||||
hit.setdefault("source_type", hit.get("source", "apps"))
|
||||
return hits
|
||||
|
||||
|
||||
def list_indexed_records(source: Optional[str] = None, limit: int = 40) -> list:
|
||||
conn = get_conn()
|
||||
if source:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM indexed_apps_records WHERE source=? ORDER BY indexed_at DESC LIMIT ?",
|
||||
(source, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM indexed_apps_records ORDER BY indexed_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -1,172 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Embedding providers — con thermal gate e keep_alive adattivo."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from . import thermal
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.embeddings")
|
||||
|
||||
|
||||
def embed_query(text: str) -> list[float]:
|
||||
"""Embedding interattivo per una query di ricerca.
|
||||
|
||||
Ordine: OLLAMA_QUERY_URL (istanza dedicata su pi1, ~0.1s, mai in coda dietro
|
||||
l'indicizzazione) → OLLAMA_URL (DS920, senza thermal gate) → OpenAI.
|
||||
Timeout corti così il client MCP riceve un errore invece di un hang.
|
||||
Cache LRU: gli agenti ripetono spesso le stesse query.
|
||||
"""
|
||||
return list(_embed_query_cached(text.strip(), os.environ.get("OLLAMA_EMBED_MODEL", "nomic-embed-text")))
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _embed_query_cached(text: str, _model: str) -> tuple[float, ...]:
|
||||
return tuple(_embed_query_uncached(text))
|
||||
|
||||
|
||||
def _embed_query_uncached(text: str) -> list[float]:
|
||||
query_url = os.environ.get("OLLAMA_QUERY_URL", "").strip()
|
||||
ollama_url = os.environ.get("OLLAMA_URL", "").strip()
|
||||
openai_key = os.environ.get("OPENAI_API_KEY", "").strip()
|
||||
last_exc: Optional[Exception] = None
|
||||
if query_url:
|
||||
try:
|
||||
# Istanza dedicata: options proprie, il modello resta caricato (keep_alive -1)
|
||||
return _embed_ollama_query(
|
||||
text, query_url, read_s=_float_env("OLLAMA_QUERY_URL_TIMEOUT_S", 10.0),
|
||||
keep_alive=-1, options=_ollama_options("OLLAMA_QUERY_NUM_THREAD"),
|
||||
)
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Ollama query (%s) failed, fallback: %s", query_url, exc)
|
||||
last_exc = exc
|
||||
if ollama_url:
|
||||
try:
|
||||
return _embed_ollama_query(
|
||||
text, ollama_url, read_s=_float_env("OLLAMA_QUERY_TIMEOUT_S", 20.0),
|
||||
keep_alive=int(_float_env("OLLAMA_KEEP_ALIVE_COOL", 300)), options=_ollama_options(),
|
||||
)
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Ollama query embedding failed: %s", exc)
|
||||
last_exc = exc
|
||||
if openai_key:
|
||||
return _embed_openai([text], openai_key, timeout=_float_env("OPENAI_QUERY_TIMEOUT_S", 20.0))[0]
|
||||
if last_exc is not None:
|
||||
raise RuntimeError(
|
||||
f"Servizio embedding (Ollama) non disponibile o lento: {last_exc}. Riprova tra poco."
|
||||
) from last_exc
|
||||
raise RuntimeError("Nessun provider embedding configurato (OLLAMA_URL o OPENAI_API_KEY)")
|
||||
|
||||
|
||||
def _float_env(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.environ.get(name, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _ollama_options(env_name: str = "OLLAMA_NUM_THREAD") -> Optional[dict]:
|
||||
# Sulla stessa istanza usare sempre le stesse options: se cambiano Ollama ricarica il modello
|
||||
num_thread = os.environ.get(env_name, "").strip()
|
||||
if num_thread:
|
||||
try:
|
||||
return {"num_thread": int(num_thread)}
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _embed_ollama_query(
|
||||
text: str, base_url: str, *, read_s: float, keep_alive: int, options: Optional[dict]
|
||||
) -> list[float]:
|
||||
model = os.environ.get("OLLAMA_EMBED_MODEL", "nomic-embed-text")
|
||||
timeout = httpx.Timeout(connect=5.0, read=read_s, write=10.0, pool=5.0)
|
||||
payload = {"model": model, "prompt": text, "keep_alive": keep_alive}
|
||||
if options:
|
||||
payload["options"] = options
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
resp = client.post(f"{base_url.rstrip('/')}/api/embeddings", json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["embedding"]
|
||||
|
||||
|
||||
def embed_texts(texts: list[str]) -> list[list[float]]:
|
||||
if not texts:
|
||||
return []
|
||||
# OLLAMA_INDEX_URL: istanza dedicata su pi1 (condivisa con le query). Nessun fallback
|
||||
# sul DS920 per l'indicizzazione: lì un chunk costa minuti, meglio riprovare al ciclo dopo
|
||||
index_url = os.environ.get("OLLAMA_INDEX_URL", "").strip()
|
||||
ollama_url = index_url or os.environ.get("OLLAMA_URL", "").strip()
|
||||
if ollama_url:
|
||||
try:
|
||||
return _embed_ollama(texts, ollama_url, dedicated=bool(index_url))
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Ollama embedding failed (%s): %s", ollama_url, exc)
|
||||
openai_key = os.environ.get("OPENAI_API_KEY", "").strip()
|
||||
if openai_key:
|
||||
return _embed_openai(texts, openai_key)
|
||||
if ollama_url:
|
||||
raise RuntimeError(f"Ollama non disponibile per l'indicizzazione ({ollama_url})")
|
||||
raise RuntimeError("Nessun provider embedding configurato (OLLAMA_URL o OPENAI_API_KEY)")
|
||||
|
||||
|
||||
def _embed_ollama(texts: list[str], base_url: str, dedicated: bool = False) -> list[list[float]]:
|
||||
"""dedicated=True: istanza condivisa con le query → stesse options e modello sempre caricato."""
|
||||
model = os.environ.get("OLLAMA_EMBED_MODEL", "nomic-embed-text")
|
||||
vectors = []
|
||||
timeout = httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
for i, text in enumerate(texts):
|
||||
status = thermal.wait_for_headroom(context=f"embed:{i+1}/{len(texts)}")
|
||||
keep_alive = -1 if dedicated else thermal.suggested_keep_alive(status)
|
||||
delay = thermal.suggested_delay_s(status)
|
||||
|
||||
payload = {"model": model, "prompt": text, "keep_alive": keep_alive}
|
||||
# options.num_thread limita i thread CPU lato Ollama (se supportato)
|
||||
options = _ollama_options("OLLAMA_QUERY_NUM_THREAD" if dedicated else "OLLAMA_NUM_THREAD")
|
||||
if options:
|
||||
payload["options"] = options
|
||||
|
||||
resp = client.post(f"{base_url.rstrip('/')}/api/embeddings", json=payload)
|
||||
if resp.status_code >= 400:
|
||||
LOGGER.warning(
|
||||
"Ollama embeddings HTTP %s: %s — payload keys=%s",
|
||||
resp.status_code,
|
||||
resp.text[:300],
|
||||
list(payload.keys()),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
vectors.append(resp.json()["embedding"])
|
||||
|
||||
if delay > 0 and i + 1 < len(texts):
|
||||
time.sleep(delay)
|
||||
|
||||
# Unload solo se esplicitamente richiesto (zona HARD) — evita spike da reload
|
||||
if keep_alive == 0:
|
||||
try:
|
||||
client.post(
|
||||
f"{base_url.rstrip('/')}/api/generate",
|
||||
json={"model": model, "keep_alive": 0},
|
||||
timeout=30.0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return vectors
|
||||
|
||||
|
||||
def _embed_openai(texts: list[str], api_key: str, timeout: float = 120.0) -> list[list[float]]:
|
||||
model = os.environ.get("OPENAI_EMBED_MODEL", "text-embedding-3-small")
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
resp = client.post(
|
||||
"https://api.openai.com/v1/embeddings",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
json={"model": model, "input": texts},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()["data"]
|
||||
return [item["embedding"] for item in sorted(data, key=lambda x: x["index"])]
|
||||
@@ -1,495 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Gitea REST API client — token per utente MCP."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.gitea")
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "https://git.loogle.it").rstrip("/")
|
||||
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||
GITEA_API_TOKEN_SCOPES = "read:repository,write:repository,write:issue,write:user,read:user"
|
||||
TEXT_EXTENSIONS = {
|
||||
".md", ".txt", ".py", ".sh", ".yml", ".yaml", ".json", ".toml", ".ini",
|
||||
".conf", ".js", ".ts", ".tsx", ".jsx", ".html", ".css", ".sql", ".go",
|
||||
".rs", ".env", ".service", ".timer", ".xml", ".csv",
|
||||
}
|
||||
_tokens_cache: Optional[dict[str, str]] = None
|
||||
|
||||
|
||||
def _load_tokens() -> dict[str, str]:
|
||||
global _tokens_cache
|
||||
if _tokens_cache is not None:
|
||||
return _tokens_cache
|
||||
|
||||
tokens: dict[str, str] = {}
|
||||
json_map = os.environ.get("GITEA_API_TOKENS", "").strip()
|
||||
if json_map:
|
||||
try:
|
||||
parsed = json.loads(json_map)
|
||||
if isinstance(parsed, dict):
|
||||
tokens.update({k.lower(): v for k, v in parsed.items() if v})
|
||||
except json.JSONDecodeError:
|
||||
LOGGER.warning("GITEA_API_TOKENS non è JSON valido")
|
||||
|
||||
fallback = os.environ.get("GITEA_API_TOKEN", "").strip()
|
||||
for user in MCP_USERS:
|
||||
env_key = f"GITEA_API_TOKEN_{user.upper()}"
|
||||
token = os.environ.get(env_key, "").strip()
|
||||
if token:
|
||||
tokens[user] = token
|
||||
elif user not in tokens and fallback:
|
||||
tokens[user] = fallback
|
||||
|
||||
if not tokens and fallback:
|
||||
tokens["daniele"] = fallback
|
||||
|
||||
_tokens_cache = tokens
|
||||
return tokens
|
||||
|
||||
|
||||
def list_configured_users() -> list[str]:
|
||||
return list(_load_tokens().keys())
|
||||
|
||||
|
||||
def is_configured(username: Optional[str] = None) -> bool:
|
||||
tokens = _load_tokens()
|
||||
if not tokens:
|
||||
return False
|
||||
if username:
|
||||
user = username.lower()
|
||||
return user in tokens or "daniele" in tokens or bool(tokens)
|
||||
return True
|
||||
|
||||
|
||||
def _headers(username: Optional[str] = None) -> dict[str, str]:
|
||||
tokens = _load_tokens()
|
||||
if not tokens:
|
||||
raise RuntimeError(
|
||||
"Nessun token Gitea configurato. "
|
||||
"Imposta GITEA_API_TOKEN o GITEA_API_TOKEN_{USER} in .env — vedi docs/GITEA-TOKEN.md"
|
||||
)
|
||||
user = (username or "daniele").lower()
|
||||
token = tokens.get(user) or tokens.get("daniele") or next(iter(tokens.values()))
|
||||
return {"Authorization": f"token {token}"}
|
||||
|
||||
|
||||
def parse_repo(repo: str) -> tuple[str, str]:
|
||||
cleaned = repo.strip().strip("/")
|
||||
if cleaned.count("/") != 1:
|
||||
raise ValueError("repo deve essere nel formato owner/name (es. daniele/rete)")
|
||||
owner, name = cleaned.split("/", 1)
|
||||
if not owner or not name:
|
||||
raise ValueError("repo deve essere nel formato owner/name (es. daniele/rete)")
|
||||
return owner, name
|
||||
|
||||
|
||||
def api_base_url() -> str:
|
||||
return os.environ.get("GITEA_API_URL", GITEA_URL).rstrip("/")
|
||||
|
||||
|
||||
def public_base_url() -> str:
|
||||
return os.environ.get("GITEA_URL", "https://git.loogle.it").rstrip("/")
|
||||
|
||||
|
||||
def _api_url(path: str) -> str:
|
||||
return f"{api_base_url()}/api/v1{path}"
|
||||
|
||||
|
||||
def _request(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
username: Optional[str] = None,
|
||||
params: Optional[dict] = None,
|
||||
json_body: Optional[dict] = None,
|
||||
) -> Any:
|
||||
with httpx.Client(timeout=httpx.Timeout(30.0, connect=5.0), verify=True) as client:
|
||||
resp = client.request(
|
||||
method,
|
||||
_api_url(path),
|
||||
headers=_headers(username),
|
||||
params=params,
|
||||
json=json_body,
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(resp.text or "Risorsa Gitea non trovata")
|
||||
resp.raise_for_status()
|
||||
if resp.content:
|
||||
return resp.json()
|
||||
return {}
|
||||
|
||||
|
||||
def list_repos(
|
||||
username: Optional[str] = None,
|
||||
page: int = 1,
|
||||
limit: int = 50,
|
||||
) -> dict:
|
||||
data = _request(
|
||||
"GET",
|
||||
"/user/repos",
|
||||
username=username,
|
||||
params={"page": page, "limit": limit, "sort": "updated"},
|
||||
)
|
||||
repos = []
|
||||
for repo in data if isinstance(data, list) else []:
|
||||
full_name = repo.get("full_name") or ""
|
||||
if not full_name and repo.get("owner"):
|
||||
full_name = f"{repo['owner'].get('login', '')}/{repo.get('name', '')}"
|
||||
repos.append(
|
||||
{
|
||||
"full_name": full_name,
|
||||
"description": repo.get("description") or "",
|
||||
"private": bool(repo.get("private")),
|
||||
"html_url": repo.get("html_url") or f"{public_base_url()}/{full_name}",
|
||||
"default_branch": repo.get("default_branch") or "main",
|
||||
"updated_at": repo.get("updated_at"),
|
||||
}
|
||||
)
|
||||
return {"repos": repos, "page": page, "count": len(repos)}
|
||||
|
||||
|
||||
def _decode_content(entry: dict) -> str:
|
||||
encoding = (entry.get("encoding") or "").lower()
|
||||
raw = entry.get("content") or ""
|
||||
if encoding == "base64":
|
||||
return base64.b64decode(raw).decode("utf-8", errors="replace")
|
||||
return raw
|
||||
|
||||
|
||||
def get_file(
|
||||
repo: str,
|
||||
path: str,
|
||||
ref: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
) -> dict:
|
||||
owner, name = parse_repo(repo)
|
||||
file_path = path.lstrip("/")
|
||||
params = {}
|
||||
if ref:
|
||||
params["ref"] = ref
|
||||
encoded_path = "/".join(quote(part, safe="") for part in file_path.split("/"))
|
||||
data = _request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{name}/contents/{encoded_path}",
|
||||
username=username,
|
||||
params=params or None,
|
||||
)
|
||||
if isinstance(data, list):
|
||||
entries = [
|
||||
{
|
||||
"name": item.get("name"),
|
||||
"path": item.get("path"),
|
||||
"type": item.get("type"),
|
||||
"size": item.get("size"),
|
||||
}
|
||||
for item in data
|
||||
]
|
||||
return {
|
||||
"repo": f"{owner}/{name}",
|
||||
"path": file_path or "/",
|
||||
"type": "dir",
|
||||
"entries": entries,
|
||||
}
|
||||
content = _decode_content(data)
|
||||
return {
|
||||
"repo": f"{owner}/{name}",
|
||||
"path": data.get("path") or file_path,
|
||||
"type": data.get("type") or "file",
|
||||
"size": data.get("size"),
|
||||
"sha": data.get("sha"),
|
||||
"html_url": data.get("html_url") or f"{public_base_url()}/{owner}/{name}/src/branch/{ref or 'main'}/{file_path}",
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
def search_code(
|
||||
query: str,
|
||||
repo: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
username: Optional[str] = None,
|
||||
) -> dict:
|
||||
q = query.strip()
|
||||
if not q:
|
||||
raise ValueError("query obbligatoria")
|
||||
params: dict[str, Any] = {"q": q, "limit": min(max(limit, 1), 50)}
|
||||
if repo:
|
||||
owner, name = parse_repo(repo)
|
||||
params["repo"] = f"{owner}/{name}"
|
||||
try:
|
||||
data = _request("GET", "/search/code", username=username, params=params)
|
||||
hits = []
|
||||
for item in data.get("data") or []:
|
||||
repo_name = item.get("repository", {}).get("full_name") or item.get("repository", {}).get("name")
|
||||
hits.append(
|
||||
{
|
||||
"repo": repo_name,
|
||||
"path": item.get("path"),
|
||||
"sha": item.get("sha"),
|
||||
"html_url": item.get("url") or item.get("html_url"),
|
||||
"language": item.get("language"),
|
||||
"snippet": (item.get("content") or item.get("text") or "")[:500],
|
||||
}
|
||||
)
|
||||
return {"query": q, "repo": repo, "results": hits, "count": len(hits)}
|
||||
except FileNotFoundError:
|
||||
return _search_code_fallback(q, repo, limit, username)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code not in (404, 422):
|
||||
raise
|
||||
return _search_code_fallback(q, repo, limit, username)
|
||||
|
||||
|
||||
def _search_code_fallback(
|
||||
query: str,
|
||||
repo: Optional[str],
|
||||
limit: int,
|
||||
username: Optional[str],
|
||||
) -> dict:
|
||||
"""Fallback se /search/code non disponibile: tree + grep su file testo."""
|
||||
repos: list[str] = []
|
||||
if repo:
|
||||
owner, name = parse_repo(repo)
|
||||
repos.append(f"{owner}/{name}")
|
||||
else:
|
||||
listed = list_repos(username=username, limit=20)
|
||||
repos = [r["full_name"] for r in listed["repos"] if r.get("full_name")]
|
||||
|
||||
terms = [t.lower() for t in re.split(r"\s+", query) if t]
|
||||
hits: list[dict] = []
|
||||
max_files = min(limit * 3, 40)
|
||||
|
||||
for full_name in repos:
|
||||
owner, name = parse_repo(full_name)
|
||||
try:
|
||||
tree = _request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{name}/git/trees/HEAD",
|
||||
username=username,
|
||||
params={"recursive": "1"},
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
scanned = 0
|
||||
for node in tree.get("tree") or []:
|
||||
if node.get("type") != "blob":
|
||||
continue
|
||||
path = node.get("path") or ""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext and ext not in TEXT_EXTENSIONS:
|
||||
continue
|
||||
if any(term in path.lower() for term in terms):
|
||||
pass
|
||||
scanned += 1
|
||||
if scanned > max_files:
|
||||
break
|
||||
try:
|
||||
file_data = get_file(full_name, path, username=username)
|
||||
except Exception:
|
||||
continue
|
||||
content = (file_data.get("content") or "").lower()
|
||||
if not any(term in content or term in path.lower() for term in terms):
|
||||
continue
|
||||
snippet = file_data.get("content") or ""
|
||||
idx = snippet.lower().find(terms[0]) if terms else 0
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
hits.append(
|
||||
{
|
||||
"repo": full_name,
|
||||
"path": path,
|
||||
"sha": node.get("sha"),
|
||||
"html_url": file_data.get("html_url"),
|
||||
"snippet": snippet[max(0, idx - 80): idx + 420],
|
||||
}
|
||||
)
|
||||
if len(hits) >= limit:
|
||||
break
|
||||
if len(hits) >= limit:
|
||||
break
|
||||
|
||||
return {"query": query, "repo": repo, "results": hits[:limit], "count": len(hits[:limit]), "mode": "fallback"}
|
||||
|
||||
|
||||
def list_issues(
|
||||
repo: str,
|
||||
state: str = "open",
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
username: Optional[str] = None,
|
||||
) -> dict:
|
||||
owner, name = parse_repo(repo)
|
||||
data = _request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{name}/issues",
|
||||
username=username,
|
||||
params={"state": state, "page": page, "limit": limit, "type": "issues"},
|
||||
)
|
||||
issues = []
|
||||
for item in data if isinstance(data, list) else []:
|
||||
issues.append(
|
||||
{
|
||||
"number": item.get("number"),
|
||||
"title": item.get("title"),
|
||||
"state": item.get("state"),
|
||||
"user": (item.get("user") or {}).get("login"),
|
||||
"html_url": item.get("html_url"),
|
||||
"created_at": item.get("created_at"),
|
||||
"updated_at": item.get("updated_at"),
|
||||
"labels": [lbl.get("name") for lbl in (item.get("labels") or [])],
|
||||
}
|
||||
)
|
||||
return {"repo": f"{owner}/{name}", "state": state, "issues": issues, "count": len(issues)}
|
||||
|
||||
|
||||
def get_issue(
|
||||
repo: str,
|
||||
number: int,
|
||||
username: Optional[str] = None,
|
||||
) -> dict:
|
||||
owner, name = parse_repo(repo)
|
||||
item = _request("GET", f"/repos/{owner}/{name}/issues/{number}", username=username)
|
||||
return {
|
||||
"repo": f"{owner}/{name}",
|
||||
"number": item.get("number"),
|
||||
"title": item.get("title"),
|
||||
"state": item.get("state"),
|
||||
"body": item.get("body") or "",
|
||||
"user": (item.get("user") or {}).get("login"),
|
||||
"html_url": item.get("html_url"),
|
||||
"created_at": item.get("created_at"),
|
||||
"updated_at": item.get("updated_at"),
|
||||
"labels": [lbl.get("name") for lbl in (item.get("labels") or [])],
|
||||
}
|
||||
|
||||
|
||||
def create_issue(
|
||||
repo: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
labels: Optional[list[str]] = None,
|
||||
username: Optional[str] = None,
|
||||
) -> dict:
|
||||
owner, name = parse_repo(repo)
|
||||
payload: dict[str, Any] = {"title": title.strip(), "body": body or ""}
|
||||
if labels:
|
||||
payload["labels"] = labels
|
||||
item = _request(
|
||||
"POST",
|
||||
f"/repos/{owner}/{name}/issues",
|
||||
username=username,
|
||||
json_body=payload,
|
||||
)
|
||||
return {
|
||||
"repo": f"{owner}/{name}",
|
||||
"number": item.get("number"),
|
||||
"title": item.get("title"),
|
||||
"state": item.get("state"),
|
||||
"html_url": item.get("html_url"),
|
||||
}
|
||||
|
||||
|
||||
def assert_repo_owner(username: str, repo: str, *, is_admin: bool = False) -> tuple[str, str]:
|
||||
owner, name = parse_repo(repo)
|
||||
if not is_admin and owner.lower() != username.lower():
|
||||
raise PermissionError(
|
||||
f"Puoi scrivere solo su repository di cui sei owner (repo {owner}/{name}, utente {username})"
|
||||
)
|
||||
return owner, name
|
||||
|
||||
|
||||
def create_repo(
|
||||
name: str,
|
||||
username: Optional[str] = None,
|
||||
*,
|
||||
private: bool = True,
|
||||
description: str = "",
|
||||
auto_init: bool = True,
|
||||
) -> dict:
|
||||
repo_name = name.strip().lower()
|
||||
if not repo_name or not re.match(r"^[a-z0-9][a-z0-9._-]{0,99}$", repo_name):
|
||||
raise ValueError("name repo non valido (usa lettere minuscole, numeri, -, _, .)")
|
||||
payload: dict[str, Any] = {
|
||||
"name": repo_name,
|
||||
"private": private,
|
||||
"auto_init": auto_init,
|
||||
"description": description.strip(),
|
||||
}
|
||||
item = _request("POST", "/user/repos", username=username, json_body=payload)
|
||||
full_name = item.get("full_name") or f"{username}/{repo_name}"
|
||||
return {
|
||||
"full_name": full_name,
|
||||
"private": bool(item.get("private", private)),
|
||||
"html_url": item.get("html_url") or f"{public_base_url()}/{full_name}",
|
||||
"default_branch": item.get("default_branch") or "main",
|
||||
"description": item.get("description") or description,
|
||||
}
|
||||
|
||||
|
||||
def create_or_update_file(
|
||||
repo: str,
|
||||
path: str,
|
||||
content: str,
|
||||
message: str,
|
||||
*,
|
||||
branch: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
) -> dict:
|
||||
owner, repo_name = parse_repo(repo)
|
||||
file_path = path.lstrip("/")
|
||||
if not file_path:
|
||||
raise ValueError("path obbligatorio")
|
||||
if not message.strip():
|
||||
raise ValueError("message commit obbligatorio")
|
||||
|
||||
encoded_path = "/".join(quote(part, safe="") for part in file_path.split("/"))
|
||||
params = {}
|
||||
if branch:
|
||||
params["ref"] = branch
|
||||
|
||||
sha = None
|
||||
action = "create"
|
||||
try:
|
||||
existing = get_file(repo, file_path, ref=branch, username=username)
|
||||
if existing.get("type") == "file":
|
||||
sha = existing.get("sha")
|
||||
action = "update"
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
||||
"message": message.strip(),
|
||||
}
|
||||
if sha:
|
||||
body["sha"] = sha
|
||||
if branch:
|
||||
body["branch"] = branch
|
||||
|
||||
method = "PUT" if sha else "POST"
|
||||
item = _request(
|
||||
method,
|
||||
f"/repos/{owner}/{repo_name}/contents/{encoded_path}",
|
||||
username=username,
|
||||
params=params or None,
|
||||
json_body=body,
|
||||
)
|
||||
commit = item.get("commit") or {}
|
||||
content_obj = item.get("content") or {}
|
||||
return {
|
||||
"repo": f"{owner}/{repo_name}",
|
||||
"path": file_path,
|
||||
"action": action,
|
||||
"branch": branch or "default",
|
||||
"sha": content_obj.get("sha"),
|
||||
"commit_sha": commit.get("sha"),
|
||||
"html_url": content_obj.get("html_url")
|
||||
or f"{public_base_url()}/{owner}/{repo_name}/src/branch/{branch or 'main'}/{file_path}",
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Indicizzazione semantica (RAG) su file testo dei repository Gitea."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import zlib
|
||||
from typing import Optional
|
||||
|
||||
from ..db import get_conn
|
||||
from . import embeddings, gitea, qdrant_store
|
||||
from .text_chunk import CHUNK_OVERLAP, CHUNK_SIZE, chunk_text as _chunk_text
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.gitea_indexer")
|
||||
|
||||
SKIP_PATH_PARTS = (
|
||||
"node_modules/",
|
||||
"vendor/",
|
||||
".git/",
|
||||
"dist/",
|
||||
"build/",
|
||||
"__pycache__/",
|
||||
".venv/",
|
||||
"venv/",
|
||||
".tox/",
|
||||
"coverage/",
|
||||
)
|
||||
PRIORITY_PREFIXES = ("docs/", "ha/", "doc/", "README")
|
||||
|
||||
# File che esistono per contenere segreti: mai indicizzati (le collezioni RAG possono
|
||||
# essere condivise con la famiglia). .env.example resta indicizzabile.
|
||||
SECRET_FILE_RE = re.compile(
|
||||
r"(^|/)(\.env(\.(?!example$)[^/]*)?|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?|\.htpasswd|\.netrc|\.pgpass"
|
||||
r"|\.claude/settings\.local\.json"
|
||||
r"|[^/]*\.(pem|key|p12|pfx|jks|keystore))$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SECRET_KEY = r"[A-Za-z0-9_.-]*(?:pass(?:word|wd)?|secret|token|api[_-]?key|private[_-]?key)[A-Za-z0-9_.-]*"
|
||||
# chiave = valore / chiave: valore (valori letterali, non espressioni di codice)
|
||||
_SECRET_ASSIGN_RE = re.compile(
|
||||
rf"(?i)\b({_SECRET_KEY})(\s*[:=]\s*)([\"']?)([^\s\"'#(){{}}\[\]$<>,;]{{4,}})\3(?=[\s\"'#;,]|$)"
|
||||
)
|
||||
# stile config "chiave valore" a inizio riga (es. keepalived: auth_pass xxxx)
|
||||
_SECRET_CONF_RE = re.compile(rf"(?im)^(\s*{_SECRET_KEY})([ \t]+)(\S{{4,}})[ \t]*$")
|
||||
# password passate a comandi: sshpass -p X, --password X / --password=X
|
||||
_SECRET_CLI_RE = re.compile(r"(sshpass\s+-p\s*|--password[=\s]\s*)(\\?[\"']?)([^\s\"'\\]{3,})")
|
||||
_PRIVATE_KEY_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S)
|
||||
|
||||
|
||||
def redact_secrets(text: str) -> str:
|
||||
"""Maschera i valori segreti prima di embedding e payload Qdrant."""
|
||||
text = _PRIVATE_KEY_RE.sub("<chiave privata rimossa>", text)
|
||||
text = _SECRET_CLI_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}<redacted>", text)
|
||||
text = _SECRET_ASSIGN_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}{m.group(3)}<redacted>{m.group(3)}", text)
|
||||
return _SECRET_CONF_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}<redacted>", text)
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
flag = os.environ.get("GITEA_INDEX_ENABLED", "yes").strip().lower()
|
||||
return flag not in ("0", "false", "no", "off")
|
||||
|
||||
|
||||
def _max_files_per_repo() -> int:
|
||||
return int(os.environ.get("GITEA_INDEX_MAX_FILES_PER_REPO", "150"))
|
||||
|
||||
|
||||
def _max_file_bytes() -> int:
|
||||
return int(os.environ.get("GITEA_INDEX_MAX_FILE_BYTES", "120000"))
|
||||
|
||||
|
||||
def _repos_for_user(username: str) -> list[str]:
|
||||
env_key = f"GITEA_INDEX_REPOS_{username.upper()}"
|
||||
raw = os.environ.get(env_key, "").strip()
|
||||
if raw:
|
||||
repos: list[str] = []
|
||||
for item in raw.split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
owner, name = gitea.parse_repo(item)
|
||||
repos.append(f"{owner}/{name}")
|
||||
return repos
|
||||
repos: list[str] = []
|
||||
page = 1
|
||||
while page <= 5:
|
||||
data = gitea.list_repos(username=username, page=page, limit=50)
|
||||
batch = [r["full_name"] for r in data.get("repos") or [] if r.get("full_name")]
|
||||
if not batch:
|
||||
break
|
||||
repos.extend(batch)
|
||||
if len(batch) < 50:
|
||||
break
|
||||
page += 1
|
||||
return repos
|
||||
|
||||
|
||||
def _should_index_path(path: str) -> bool:
|
||||
lowered = path.lower()
|
||||
if any(part in lowered for part in SKIP_PATH_PARTS) or SECRET_FILE_RE.search(path):
|
||||
return False
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
return bool(ext and ext in gitea.TEXT_EXTENSIONS)
|
||||
|
||||
|
||||
def _path_priority(path: str) -> tuple[int, str]:
|
||||
lowered = path.lower()
|
||||
for idx, prefix in enumerate(PRIORITY_PREFIXES):
|
||||
if lowered.startswith(prefix.lower()) or os.path.basename(lowered).startswith(prefix.lower()):
|
||||
return (idx, path)
|
||||
return (len(PRIORITY_PREFIXES), path)
|
||||
|
||||
|
||||
def list_repo_text_files(repo: str, username: str) -> list[dict]:
|
||||
owner, name = gitea.parse_repo(repo)
|
||||
tree = gitea._request(
|
||||
"GET",
|
||||
f"/repos/{owner}/{name}/git/trees/HEAD",
|
||||
username=username,
|
||||
params={"recursive": "1"},
|
||||
)
|
||||
max_files = _max_files_per_repo()
|
||||
max_bytes = _max_file_bytes()
|
||||
files: list[dict] = []
|
||||
for node in tree.get("tree") or []:
|
||||
if node.get("type") != "blob":
|
||||
continue
|
||||
path = node.get("path") or ""
|
||||
if not _should_index_path(path):
|
||||
continue
|
||||
size = int(node.get("size") or 0)
|
||||
if size > max_bytes:
|
||||
continue
|
||||
files.append({"path": path, "sha": node.get("sha"), "size": size})
|
||||
files.sort(key=lambda item: _path_priority(item["path"]))
|
||||
return files[:max_files]
|
||||
|
||||
|
||||
def _repo_owner(repo: str) -> str:
|
||||
owner, _ = gitea.parse_repo(repo)
|
||||
return owner.lower()
|
||||
|
||||
|
||||
def _repo_visibility(private: bool, owner: str) -> str:
|
||||
if private:
|
||||
return "personal"
|
||||
if owner in gitea.MCP_USERS:
|
||||
return "family"
|
||||
return "family"
|
||||
|
||||
|
||||
def _collection_for_repo(private: bool, owner: str) -> str:
|
||||
visibility = _repo_visibility(private, owner)
|
||||
if visibility == "personal":
|
||||
return qdrant_store.gitea_collection(owner)
|
||||
return qdrant_store.GITEA_SHARED_COLLECTION
|
||||
|
||||
|
||||
def _file_doc_id(repo: str, path: str) -> int:
|
||||
return zlib.adler32(f"{repo}:{path}".encode("utf-8")) & 0x7FFFFFFF
|
||||
|
||||
|
||||
def _file_title(repo: str, path: str) -> str:
|
||||
return f"{repo}/{path}"
|
||||
|
||||
|
||||
def index_file(
|
||||
repo: str,
|
||||
path: str,
|
||||
*,
|
||||
username: str,
|
||||
private: bool,
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
owner = _repo_owner(repo)
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT sha, chunk_count FROM indexed_gitea_files WHERE repo=? AND path=?",
|
||||
(repo, path),
|
||||
).fetchone()
|
||||
|
||||
file_data = gitea.get_file(repo, path, username=username)
|
||||
sha = file_data.get("sha") or ""
|
||||
if row and row["sha"] == sha and not force:
|
||||
return {"repo": repo, "path": path, "skipped": True, "sha": sha}
|
||||
|
||||
text = redact_secrets((file_data.get("content") or "").strip())
|
||||
if not text:
|
||||
return {"repo": repo, "path": path, "chunks": 0, "sha": sha}
|
||||
|
||||
header = f"# {_file_title(repo, path)}\n\nSource: gitea:{repo}:{path}\n\n"
|
||||
chunks = _chunk_text(header + text)
|
||||
if not chunks:
|
||||
return {"repo": repo, "path": path, "chunks": 0, "sha": sha}
|
||||
|
||||
visibility = _repo_visibility(private, owner)
|
||||
collection = _collection_for_repo(private, owner)
|
||||
doc_id = _file_doc_id(repo, path)
|
||||
qdrant_store.delete_by_doc(collection, doc_id)
|
||||
|
||||
vectors = embeddings.embed_texts(chunks)
|
||||
ids = []
|
||||
payloads = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
point_id = f"gitea-{repo}-{path}-chunk-{i}"
|
||||
ids.append(point_id)
|
||||
payloads.append(
|
||||
{
|
||||
"source": "gitea",
|
||||
"doc_id": doc_id,
|
||||
"repo": repo,
|
||||
"path": path,
|
||||
"chunk_index": i,
|
||||
"title": _file_title(repo, path),
|
||||
"text": chunk,
|
||||
"owner": owner,
|
||||
"visibility": visibility,
|
||||
"sha": sha,
|
||||
}
|
||||
)
|
||||
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
|
||||
|
||||
stored = qdrant_store.count_by_doc(collection, doc_id)
|
||||
if stored < len(chunks):
|
||||
raise RuntimeError(
|
||||
f"Qdrant upsert incompleto per {repo}/{path}: attesi {len(chunks)} chunk, trovati {stored}"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO indexed_gitea_files(repo,path,sha,owner,visibility,chunk_count,indexed_at)"
|
||||
" VALUES (?,?,?,?,?,?,datetime('now'))"
|
||||
" ON CONFLICT(repo,path) DO UPDATE SET"
|
||||
" sha=excluded.sha, owner=excluded.owner, visibility=excluded.visibility,"
|
||||
" chunk_count=excluded.chunk_count, indexed_at=datetime('now')",
|
||||
(repo, path, sha, owner, visibility, len(chunks)),
|
||||
)
|
||||
conn.commit()
|
||||
return {
|
||||
"repo": repo,
|
||||
"path": path,
|
||||
"sha": sha,
|
||||
"chunks": len(chunks),
|
||||
"collection": collection,
|
||||
"visibility": visibility,
|
||||
}
|
||||
|
||||
|
||||
def index_repo(
|
||||
repo: str,
|
||||
*,
|
||||
username: Optional[str] = None,
|
||||
private: Optional[bool] = None,
|
||||
force: bool = False,
|
||||
max_files: Optional[int] = None,
|
||||
) -> dict:
|
||||
gitea_user = username or _repo_owner(repo)
|
||||
if not gitea.is_configured(gitea_user):
|
||||
raise RuntimeError(f"Gitea non configurato per {gitea_user}")
|
||||
|
||||
if private is None:
|
||||
owner, name = gitea.parse_repo(repo)
|
||||
meta = gitea._request("GET", f"/repos/{owner}/{name}", username=gitea_user)
|
||||
private = bool(meta.get("private"))
|
||||
|
||||
files = list_repo_text_files(repo, gitea_user)
|
||||
if max_files is not None:
|
||||
files = files[: max(1, max_files)]
|
||||
|
||||
indexed = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
chunks = 0
|
||||
for item in files:
|
||||
try:
|
||||
result = index_file(
|
||||
repo,
|
||||
item["path"],
|
||||
username=gitea_user,
|
||||
private=bool(private),
|
||||
force=force,
|
||||
)
|
||||
if result.get("skipped"):
|
||||
skipped += 1
|
||||
else:
|
||||
indexed += 1
|
||||
chunks += int(result.get("chunks") or 0)
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Index gitea %s/%s failed: %s", repo, item.get("path"), exc)
|
||||
errors += 1
|
||||
|
||||
return {
|
||||
"repo": repo,
|
||||
"files_seen": len(files),
|
||||
"files_indexed": indexed,
|
||||
"files_skipped": skipped,
|
||||
"errors": errors,
|
||||
"chunks": chunks,
|
||||
"private": bool(private),
|
||||
}
|
||||
|
||||
|
||||
def index_all(max_files_per_repo: Optional[int] = None) -> dict:
|
||||
if not _enabled():
|
||||
return {"enabled": False, "indexed_files": 0}
|
||||
|
||||
total_indexed = 0
|
||||
total_skipped = 0
|
||||
total_errors = 0
|
||||
total_chunks = 0
|
||||
repos_done: list[str] = []
|
||||
users = gitea.list_configured_users() or []
|
||||
|
||||
for username in users:
|
||||
try:
|
||||
repos = _repos_for_user(username)
|
||||
except Exception as exc:
|
||||
LOGGER.error("Lista repo Gitea fallita per %s: %s", username, exc)
|
||||
continue
|
||||
for repo in repos:
|
||||
try:
|
||||
result = index_repo(
|
||||
repo,
|
||||
username=username,
|
||||
force=False,
|
||||
max_files=max_files_per_repo,
|
||||
)
|
||||
repos_done.append(repo)
|
||||
total_indexed += int(result.get("files_indexed") or 0)
|
||||
total_skipped += int(result.get("files_skipped") or 0)
|
||||
total_errors += int(result.get("errors") or 0)
|
||||
total_chunks += int(result.get("chunks") or 0)
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Index repo %s failed (%s): %s", repo, username, exc)
|
||||
total_errors += 1
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"repos": repos_done,
|
||||
"files_indexed": total_indexed,
|
||||
"files_skipped": total_skipped,
|
||||
"errors": total_errors,
|
||||
"chunks": total_chunks,
|
||||
"users": users,
|
||||
}
|
||||
|
||||
|
||||
def gitea_collections_for_user(username: str, is_admin: bool = False) -> list[str]:
|
||||
cols = [qdrant_store.GITEA_SHARED_COLLECTION, qdrant_store.gitea_collection(username)]
|
||||
if is_admin:
|
||||
for user in gitea.MCP_USERS:
|
||||
cols.append(qdrant_store.gitea_collection(user))
|
||||
return list(dict.fromkeys(cols))
|
||||
|
||||
|
||||
def search_gitea_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list:
|
||||
vectors = [embeddings.embed_query(query)]
|
||||
collections = gitea_collections_for_user(username, is_admin=is_admin)
|
||||
hits = qdrant_store.search(collections, vectors[0], limit=limit)
|
||||
for hit in hits:
|
||||
hit["source"] = hit.get("source") or "gitea"
|
||||
return hits
|
||||
|
||||
|
||||
def list_indexed_files(limit: int = 30, repo: Optional[str] = None) -> list:
|
||||
conn = get_conn()
|
||||
if repo:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM indexed_gitea_files WHERE repo=? ORDER BY indexed_at DESC LIMIT ?",
|
||||
(repo, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM indexed_gitea_files ORDER BY indexed_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def index_stats() -> dict:
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*), COALESCE(SUM(chunk_count), 0) FROM indexed_gitea_files"
|
||||
).fetchone()
|
||||
files_count = int(row[0] if row else 0)
|
||||
chunks_meta = int(row[1] if row else 0)
|
||||
collections = {qdrant_store.GITEA_SHARED_COLLECTION}
|
||||
for user in gitea.MCP_USERS:
|
||||
collections.add(qdrant_store.gitea_collection(user))
|
||||
qdrant_points = sum(qdrant_store.collection_point_count(c) for c in collections)
|
||||
return {
|
||||
"files_indexed": files_count,
|
||||
"chunks_in_metadata": chunks_meta,
|
||||
"qdrant_points": qdrant_points,
|
||||
"collections": {c: qdrant_store.collection_point_count(c) for c in sorted(collections)},
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Paperless → Qdrant indexing pipeline."""
|
||||
|
||||
import logging
|
||||
import zlib
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Optional
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.indexer")
|
||||
|
||||
# Indicizzazioni avviate dai tool MCP: fuori dalla richiesta, una alla volta (thermal gate)
|
||||
_background = ThreadPoolExecutor(max_workers=1, thread_name_prefix="bg-index")
|
||||
|
||||
from ..db import get_conn
|
||||
from . import embeddings, gitea_indexer, paperless, qdrant_store
|
||||
from .text_chunk import chunk_text as _chunk_text
|
||||
|
||||
|
||||
def index_document(doc_id: int, force: bool = False, paperless_user: Optional[str] = None) -> dict:
|
||||
conn = get_conn()
|
||||
existing = conn.execute(
|
||||
"SELECT doc_id FROM indexed_documents WHERE doc_id=?", (doc_id,)
|
||||
).fetchone()
|
||||
if existing and not force:
|
||||
return {"doc_id": doc_id, "skipped": True}
|
||||
doc = paperless.get_document(doc_id, username=paperless_user)
|
||||
text = paperless.download_document_text(doc_id, username=paperless_user)
|
||||
owner = paperless.document_owner(doc)
|
||||
visibility = paperless.document_visibility(doc, owner)
|
||||
chunks = _chunk_text(text)
|
||||
if not chunks:
|
||||
return {"doc_id": doc_id, "chunks": 0}
|
||||
vectors = embeddings.embed_texts(chunks)
|
||||
collection = qdrant_store.SHARED_COLLECTION if visibility == "family" else qdrant_store.kb_collection(owner)
|
||||
qdrant_store.delete_by_doc(collection, doc_id)
|
||||
ids = []
|
||||
payloads = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
point_id = f"doc-{doc_id}-chunk-{i}"
|
||||
ids.append(point_id)
|
||||
payloads.append(
|
||||
{
|
||||
"doc_id": doc_id,
|
||||
"chunk_index": i,
|
||||
"title": doc.get("title") or f"Documento {doc_id}",
|
||||
"text": chunk,
|
||||
"owner": owner,
|
||||
"visibility": visibility,
|
||||
}
|
||||
)
|
||||
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
|
||||
conn.execute(
|
||||
"INSERT INTO indexed_documents(doc_id,title,owner,visibility,chunk_count,indexed_at)"
|
||||
" VALUES (?,?,?,?,?,datetime('now'))"
|
||||
" ON CONFLICT(doc_id) DO UPDATE SET"
|
||||
" title=excluded.title, owner=excluded.owner, visibility=excluded.visibility,"
|
||||
" chunk_count=excluded.chunk_count, indexed_at=datetime('now')",
|
||||
(doc_id, doc.get("title"), owner, visibility, len(chunks)),
|
||||
)
|
||||
conn.commit()
|
||||
return {"doc_id": doc_id, "chunks": len(chunks), "collection": collection}
|
||||
|
||||
|
||||
def index_all(max_pages: int = 20) -> dict:
|
||||
indexed = 0
|
||||
errors = 0
|
||||
seen: set[int] = set()
|
||||
users = paperless.list_configured_users() or ["daniele"]
|
||||
for paperless_user in users:
|
||||
page = 1
|
||||
while page <= max_pages:
|
||||
try:
|
||||
data = paperless.list_documents(page=page, page_size=25, username=paperless_user)
|
||||
except Exception as exc:
|
||||
LOGGER.error("Paperless list failed for %s: %s", paperless_user, exc)
|
||||
break
|
||||
results = data.get("results") or []
|
||||
if not results:
|
||||
break
|
||||
for doc in results:
|
||||
doc_id = doc["id"]
|
||||
if doc_id in seen:
|
||||
continue
|
||||
seen.add(doc_id)
|
||||
try:
|
||||
index_document(doc_id, paperless_user=paperless_user)
|
||||
indexed += 1
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Index doc %s failed (%s): %s", doc_id, paperless_user, exc)
|
||||
errors += 1
|
||||
if not data.get("next"):
|
||||
break
|
||||
page += 1
|
||||
return {"indexed": indexed, "errors": errors, "users": users}
|
||||
|
||||
|
||||
def index_context_snippet(
|
||||
username: str,
|
||||
project_id: str,
|
||||
text: str,
|
||||
title: str,
|
||||
snippet_id: Optional[str] = None,
|
||||
) -> None:
|
||||
chunks = _chunk_text(text)
|
||||
if not chunks:
|
||||
return
|
||||
vectors = embeddings.embed_texts(chunks)
|
||||
collection = qdrant_store.ctx_collection(username)
|
||||
doc_key = f"ctx-{username}-{project_id}"
|
||||
if snippet_id:
|
||||
doc_key = f"{doc_key}-{snippet_id}"
|
||||
# crc32 e non hash(): hash() cambia a ogni processo e il delete non trovava i vecchi chunk
|
||||
doc_id = zlib.crc32(doc_key.encode()) % (2**31)
|
||||
qdrant_store.delete_by_doc(collection, doc_id)
|
||||
ids = []
|
||||
payloads = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
point_id = f"{doc_key}-chunk-{i}"
|
||||
ids.append(point_id)
|
||||
payloads.append(
|
||||
{
|
||||
"doc_id": doc_id,
|
||||
"project_id": project_id,
|
||||
"chunk_index": i,
|
||||
"title": title,
|
||||
"text": chunk,
|
||||
"owner": username,
|
||||
"visibility": "personal",
|
||||
"source": "context",
|
||||
"snippet_id": snippet_id or "latest",
|
||||
}
|
||||
)
|
||||
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
|
||||
|
||||
|
||||
def index_project_context(username: str, project_id: str) -> dict:
|
||||
"""Indicizza il context.md completo del progetto sostituendo la versione precedente.
|
||||
|
||||
Personali → ctx_{utente}; condivisi → ctx_shared_family (visibili a tutta la famiglia).
|
||||
Così un contesto che cambia (es. inventario) non lascia in ricerca informazioni superate.
|
||||
"""
|
||||
from ..context import store as context_store
|
||||
|
||||
data = context_store.get_project_context(username, project_id, session_limit=0, include_gitea=False)
|
||||
meta = data["meta"]
|
||||
shared = bool(meta.get("shared"))
|
||||
collection = qdrant_store.CTX_SHARED_COLLECTION if shared else qdrant_store.ctx_collection(username)
|
||||
namespace = context_store.SHARED_NS if shared else username
|
||||
drop_project_context(collection, project_id)
|
||||
chunks = _chunk_text(data["context_md"])
|
||||
if not chunks:
|
||||
return {"project_id": project_id, "collection": collection, "chunks": 0}
|
||||
vectors = embeddings.embed_texts(chunks)
|
||||
doc_key = f"ctx-{namespace}-{project_id}"
|
||||
doc_id = zlib.crc32(doc_key.encode()) % (2**31)
|
||||
ids, payloads = [], []
|
||||
for i, chunk in enumerate(chunks):
|
||||
ids.append(f"{doc_key}-chunk-{i}")
|
||||
payloads.append(
|
||||
{
|
||||
"doc_id": doc_id,
|
||||
"project_id": project_id,
|
||||
"chunk_index": i,
|
||||
"title": meta.get("title") or project_id,
|
||||
"text": chunk,
|
||||
"owner": meta.get("owner") or username,
|
||||
"visibility": "family" if shared else "personal",
|
||||
"shared": shared,
|
||||
"updated_by": meta.get("updated_by"),
|
||||
"source": "context",
|
||||
"snippet_id": "full",
|
||||
}
|
||||
)
|
||||
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
|
||||
return {"project_id": project_id, "collection": collection, "chunks": len(chunks)}
|
||||
|
||||
|
||||
_DIGEST_SNIPPET_PREFIXES = ("daily-", "plan-")
|
||||
|
||||
|
||||
def drop_project_context(collection: str, project_id: str) -> int:
|
||||
"""Rimuove tutti i chunk di un progetto (anche quelli indicizzati a pezzi in passato),
|
||||
tranne gli snippet del digest giornaliero (snippet_id daily-*/plan-*)."""
|
||||
return qdrant_store.delete_matching(
|
||||
collection,
|
||||
"project_id",
|
||||
project_id,
|
||||
lambda payload: not str(payload.get("snippet_id") or "").startswith(_DIGEST_SNIPPET_PREFIXES),
|
||||
)
|
||||
|
||||
|
||||
def submit_background(label: str, fn, *args, **kwargs) -> None:
|
||||
"""Esegue fn in background; gli errori finiscono nel log invece che al client."""
|
||||
|
||||
def _run():
|
||||
try:
|
||||
fn(*args, **kwargs)
|
||||
LOGGER.info("Indicizzazione background completata: %s", label)
|
||||
except Exception:
|
||||
LOGGER.exception("Indicizzazione background fallita: %s", label)
|
||||
|
||||
_background.submit(_run)
|
||||
|
||||
|
||||
def search_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list:
|
||||
vectors = [embeddings.embed_query(query)]
|
||||
collections = [
|
||||
qdrant_store.SHARED_COLLECTION,
|
||||
qdrant_store.kb_collection(username),
|
||||
qdrant_store.ctx_collection(username),
|
||||
qdrant_store.CTX_SHARED_COLLECTION,
|
||||
]
|
||||
collections.extend(gitea_indexer.gitea_collections_for_user(username, is_admin=is_admin))
|
||||
collections.append(qdrant_store.APPS_SHARED_COLLECTION)
|
||||
if is_admin:
|
||||
for u in ("daniele", "lucia", "davide", "luca"):
|
||||
collections.append(qdrant_store.kb_collection(u))
|
||||
collections = list(dict.fromkeys(collections))
|
||||
hits = qdrant_store.search(collections, vectors[0], limit=limit)
|
||||
for hit in hits:
|
||||
if hit.get("repo") and not hit.get("source"):
|
||||
hit["source"] = "gitea"
|
||||
elif hit.get("source") in ("irrigazione", "turni"):
|
||||
hit["source_type"] = "apps"
|
||||
elif hit.get("doc_id") and not hit.get("source"):
|
||||
hit["source"] = "paperless"
|
||||
return hits
|
||||
|
||||
|
||||
def search_gitea_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list:
|
||||
return gitea_indexer.search_gitea_knowledge(username, query, limit=limit, is_admin=is_admin)
|
||||
|
||||
|
||||
def search_context(username: str, query: str, limit: int = 8) -> list:
|
||||
vectors = [embeddings.embed_query(query)]
|
||||
collections = [qdrant_store.ctx_collection(username), qdrant_store.CTX_SHARED_COLLECTION]
|
||||
return qdrant_store.search(collections, vectors[0], limit=limit)
|
||||
|
||||
|
||||
def list_recent_documents(limit: int = 20) -> list:
|
||||
rows = get_conn().execute(
|
||||
"SELECT * FROM indexed_documents ORDER BY indexed_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -1,140 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Paperless-ngx REST API client — supporto token per utente MCP."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.paperless")
|
||||
|
||||
PAPERLESS_URL = os.environ.get("PAPERLESS_URL", "https://docs.loogle.it").rstrip("/")
|
||||
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||
_tokens_cache: Optional[dict[str, str]] = None
|
||||
|
||||
|
||||
def _load_tokens() -> dict[str, str]:
|
||||
"""Carica token Paperless per utente MCP.
|
||||
|
||||
Priorità per ogni utente:
|
||||
1. PAPERLESS_API_TOKEN_{USERNAME} (es. PAPERLESS_API_TOKEN_LUCIA)
|
||||
2. Chiavi in PAPERLESS_API_TOKENS JSON (es. {"daniele":"...", "lucia":"..."})
|
||||
3. PAPERLESS_API_TOKEN globale (fallback per tutti, tipico account admin)
|
||||
"""
|
||||
global _tokens_cache
|
||||
if _tokens_cache is not None:
|
||||
return _tokens_cache
|
||||
|
||||
tokens: dict[str, str] = {}
|
||||
json_map = os.environ.get("PAPERLESS_API_TOKENS", "").strip()
|
||||
if json_map:
|
||||
try:
|
||||
parsed = json.loads(json_map)
|
||||
if isinstance(parsed, dict):
|
||||
tokens.update({k.lower(): v for k, v in parsed.items() if v})
|
||||
except json.JSONDecodeError:
|
||||
LOGGER.warning("PAPERLESS_API_TOKENS non è JSON valido")
|
||||
|
||||
fallback = os.environ.get("PAPERLESS_API_TOKEN", "").strip()
|
||||
for user in MCP_USERS:
|
||||
env_key = f"PAPERLESS_API_TOKEN_{user.upper()}"
|
||||
token = os.environ.get(env_key, "").strip()
|
||||
if token:
|
||||
tokens[user] = token
|
||||
elif user not in tokens and fallback:
|
||||
tokens[user] = fallback
|
||||
|
||||
if not tokens and fallback:
|
||||
tokens["daniele"] = fallback
|
||||
|
||||
_tokens_cache = tokens
|
||||
return tokens
|
||||
|
||||
|
||||
def list_configured_users() -> list[str]:
|
||||
return list(_load_tokens().keys())
|
||||
|
||||
|
||||
def _headers(username: Optional[str] = None) -> dict:
|
||||
tokens = _load_tokens()
|
||||
if not tokens:
|
||||
raise RuntimeError(
|
||||
"Nessun token Paperless configurato. "
|
||||
"Imposta PAPERLESS_API_TOKEN o PAPERLESS_API_TOKEN_{USER} in .env"
|
||||
)
|
||||
user = (username or "daniele").lower()
|
||||
token = tokens.get(user) or tokens.get("daniele") or next(iter(tokens.values()))
|
||||
return {"Authorization": f"Token {token}"}
|
||||
|
||||
|
||||
def list_documents(
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
ordering: str = "-modified",
|
||||
username: Optional[str] = None,
|
||||
) -> dict:
|
||||
with httpx.Client(timeout=httpx.Timeout(60.0, connect=5.0), verify=True) as client:
|
||||
resp = client.get(
|
||||
f"{PAPERLESS_URL}/api/documents/",
|
||||
headers=_headers(username),
|
||||
params={"page": page, "page_size": page_size, "ordering": ordering},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def get_document(doc_id: int, username: Optional[str] = None) -> dict:
|
||||
with httpx.Client(timeout=httpx.Timeout(60.0, connect=5.0), verify=True) as client:
|
||||
resp = client.get(
|
||||
f"{PAPERLESS_URL}/api/documents/{doc_id}/",
|
||||
headers=_headers(username),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def download_document_text(doc_id: int, username: Optional[str] = None) -> str:
|
||||
doc = get_document(doc_id, username=username)
|
||||
content = (doc.get("content") or "").strip()
|
||||
if content:
|
||||
return content
|
||||
title = doc.get("title") or f"Documento {doc_id}"
|
||||
return f"# {title}\n\n(Nessun testo OCR disponibile)"
|
||||
|
||||
|
||||
def document_visibility(doc: dict, owner_username: str) -> str:
|
||||
tags = doc.get("tags") or []
|
||||
tag_names = []
|
||||
for t in tags:
|
||||
if isinstance(t, dict):
|
||||
tag_names.append((t.get("name") or "").lower())
|
||||
elif isinstance(t, int):
|
||||
continue
|
||||
else:
|
||||
tag_names.append(str(t).lower())
|
||||
if any(t in ("personal", "privato", "private") for t in tag_names):
|
||||
return "personal"
|
||||
if any(t in ("admin-only", "admin") for t in tag_names):
|
||||
return "admin"
|
||||
return "family"
|
||||
|
||||
|
||||
def document_owner(doc: dict, default: str = "daniele") -> str:
|
||||
owner = doc.get("owner")
|
||||
if isinstance(owner, int):
|
||||
pass
|
||||
owner_username = doc.get("owner_username") or doc.get("owner_name")
|
||||
if isinstance(owner_username, str):
|
||||
name = owner_username.lower()
|
||||
for user in MCP_USERS:
|
||||
if user in name:
|
||||
return user
|
||||
correspondent = doc.get("correspondent")
|
||||
if isinstance(correspondent, dict):
|
||||
name = (correspondent.get("name") or "").lower()
|
||||
for user in MCP_USERS:
|
||||
if user in name:
|
||||
return user
|
||||
return default
|
||||
@@ -1,307 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Vector store — Qdrant remoto (DS920) con fallback SQLite locale su Pi ARM."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.vector_store")
|
||||
VECTOR_SIZE = 768
|
||||
_local = sqlite3.connect(":memory:", check_same_thread=False) # placeholder
|
||||
_qdrant_client = None
|
||||
_next_qdrant_check = 0.0 # monotonic: prossimo tentativo di connessione mentre si usa il fallback
|
||||
_qdrant_lock = threading.Lock()
|
||||
|
||||
|
||||
def _fallback_path() -> str:
|
||||
return os.environ.get("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
|
||||
|
||||
|
||||
def _fallback_conn() -> sqlite3.Connection:
|
||||
path = _fallback_path()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
conn = sqlite3.connect(path, timeout=30)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS vectors (
|
||||
id TEXT PRIMARY KEY,
|
||||
collection TEXT NOT NULL,
|
||||
vector TEXT NOT NULL,
|
||||
payload TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_vectors_collection ON vectors(collection)")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def _get_qdrant():
|
||||
"""Client Qdrant, oppure None (fallback SQLite).
|
||||
|
||||
Se Qdrant non risponde si usa il fallback, ma si riprova ogni QDRANT_RETRY_S secondi
|
||||
invece di restare sul fallback fino al riavvio.
|
||||
"""
|
||||
global _next_qdrant_check
|
||||
if _qdrant_client is not None or time.monotonic() < _next_qdrant_check:
|
||||
return _qdrant_client
|
||||
with _qdrant_lock:
|
||||
if _qdrant_client is not None or time.monotonic() < _next_qdrant_check:
|
||||
return _qdrant_client
|
||||
client = _connect_qdrant()
|
||||
if client is None:
|
||||
_next_qdrant_check = time.monotonic() + float(os.environ.get("QDRANT_RETRY_S", "60"))
|
||||
return client
|
||||
|
||||
|
||||
def _connect_qdrant():
|
||||
global _qdrant_client
|
||||
url = os.environ.get("QDRANT_URL", "").strip()
|
||||
if not url:
|
||||
LOGGER.warning("QDRANT_URL non impostato — fallback SQLite")
|
||||
return None
|
||||
try:
|
||||
import httpx
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
# Sonda breve: un tentativo con Qdrant giù non deve bloccare la ricerca per 20s
|
||||
httpx.get(f"{url.rstrip('/')}/readyz", timeout=3.0).raise_for_status()
|
||||
client = QdrantClient(url=url, timeout=20)
|
||||
client.get_collections()
|
||||
_qdrant_client = client
|
||||
globals()["qm"] = qm
|
||||
LOGGER.info("Qdrant connesso: %s", url)
|
||||
return client
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Qdrant non disponibile (%s) — fallback SQLite, nuovo tentativo tra %ss",
|
||||
exc, os.environ.get("QDRANT_RETRY_S", "60"))
|
||||
return None
|
||||
|
||||
|
||||
def ensure_collection(name: str, vector_size: int = VECTOR_SIZE) -> None:
|
||||
client = _get_qdrant()
|
||||
if client is None:
|
||||
return
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
names = {c.name for c in client.get_collections().collections}
|
||||
if name in names:
|
||||
return
|
||||
client.create_collection(
|
||||
collection_name=name,
|
||||
vectors_config=qm.VectorParams(size=vector_size, distance=qm.Distance.COSINE),
|
||||
)
|
||||
|
||||
|
||||
def kb_collection(username: str) -> str:
|
||||
return f"kb_personal_{username}"
|
||||
|
||||
|
||||
def ctx_collection(username: str) -> str:
|
||||
return f"ctx_{username}"
|
||||
|
||||
|
||||
SHARED_COLLECTION = "kb_shared_family"
|
||||
GITEA_SHARED_COLLECTION = "gitea_shared_family"
|
||||
APPS_SHARED_COLLECTION = "apps_shared_family"
|
||||
CTX_SHARED_COLLECTION = "ctx_shared_family"
|
||||
|
||||
|
||||
def gitea_collection(username: str) -> str:
|
||||
return f"gitea_personal_{username}"
|
||||
|
||||
|
||||
def _point_id(name: str) -> str:
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, name))
|
||||
|
||||
|
||||
def _cosine(a: list[float], b: list[float]) -> float:
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = math.sqrt(sum(x * x for x in a)) or 1.0
|
||||
nb = math.sqrt(sum(x * x for x in b)) or 1.0
|
||||
return dot / (na * nb)
|
||||
|
||||
|
||||
def upsert_chunks(
|
||||
collection: str,
|
||||
ids: list[str],
|
||||
vectors: list[list[float]],
|
||||
payloads: list[dict],
|
||||
) -> None:
|
||||
if not vectors:
|
||||
return
|
||||
ensure_collection(collection, len(vectors[0]))
|
||||
client = _get_qdrant()
|
||||
if client is not None:
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
points = [
|
||||
qm.PointStruct(id=_point_id(pid), vector=vec, payload=payload)
|
||||
for pid, vec, payload in zip(ids, vectors, payloads)
|
||||
]
|
||||
client.upsert(collection_name=collection, points=points)
|
||||
return
|
||||
conn = _fallback_conn()
|
||||
for pid, vec, payload in zip(ids, vectors, payloads):
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO vectors(id,collection,vector,payload) VALUES (?,?,?,?)",
|
||||
(_point_id(pid), collection, json.dumps(vec), json.dumps(payload, ensure_ascii=False)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_by_doc(collection: str, doc_id: int) -> None:
|
||||
delete_where(collection, "doc_id", doc_id)
|
||||
|
||||
|
||||
def delete_where(collection: str, key: str, value) -> None:
|
||||
"""Elimina i punti il cui payload[key] == value."""
|
||||
client = _get_qdrant()
|
||||
if client is not None:
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
ensure_collection(collection)
|
||||
client.delete(
|
||||
collection_name=collection,
|
||||
points_selector=qm.FilterSelector(
|
||||
filter=qm.Filter(
|
||||
must=[qm.FieldCondition(key=key, match=qm.MatchValue(value=value))]
|
||||
)
|
||||
),
|
||||
)
|
||||
return
|
||||
conn = _fallback_conn()
|
||||
rows = conn.execute("SELECT id,payload FROM vectors WHERE collection=?", (collection,)).fetchall()
|
||||
for row_id, payload_raw in rows:
|
||||
payload = json.loads(payload_raw)
|
||||
if payload.get(key) == value:
|
||||
conn.execute("DELETE FROM vectors WHERE id=?", (row_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_matching(collection: str, key: str, value, predicate) -> int:
|
||||
"""Elimina i punti con payload[key] == value per cui predicate(payload) è vero."""
|
||||
client = _get_qdrant()
|
||||
if client is not None:
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
ensure_collection(collection)
|
||||
flt = qm.Filter(must=[qm.FieldCondition(key=key, match=qm.MatchValue(value=value))])
|
||||
ids, offset = [], None
|
||||
while True:
|
||||
points, offset = client.scroll(
|
||||
collection_name=collection, scroll_filter=flt, limit=256, offset=offset, with_payload=True
|
||||
)
|
||||
ids.extend(p.id for p in points if predicate(p.payload or {}))
|
||||
if offset is None:
|
||||
break
|
||||
if ids:
|
||||
client.delete(collection_name=collection, points_selector=qm.PointIdsList(points=ids))
|
||||
return len(ids)
|
||||
conn = _fallback_conn()
|
||||
rows = conn.execute("SELECT id,payload FROM vectors WHERE collection=?", (collection,)).fetchall()
|
||||
removed = 0
|
||||
for row_id, payload_raw in rows:
|
||||
payload = json.loads(payload_raw)
|
||||
if payload.get(key) == value and predicate(payload):
|
||||
conn.execute("DELETE FROM vectors WHERE id=?", (row_id,))
|
||||
removed += 1
|
||||
conn.commit()
|
||||
return removed
|
||||
|
||||
|
||||
def count_by_doc(collection: str, doc_id: int) -> int:
|
||||
client = _get_qdrant()
|
||||
if client is not None:
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
ensure_collection(collection)
|
||||
result = client.count(
|
||||
collection_name=collection,
|
||||
count_filter=qm.Filter(
|
||||
must=[qm.FieldCondition(key="doc_id", match=qm.MatchValue(value=doc_id))]
|
||||
),
|
||||
exact=True,
|
||||
)
|
||||
return int(result.count)
|
||||
conn = _fallback_conn()
|
||||
rows = conn.execute("SELECT payload FROM vectors WHERE collection=?", (collection,)).fetchall()
|
||||
count = 0
|
||||
for (payload_raw,) in rows:
|
||||
payload = json.loads(payload_raw)
|
||||
if payload.get("doc_id") == doc_id:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def collection_point_count(collection: str) -> int:
|
||||
client = _get_qdrant()
|
||||
if client is not None:
|
||||
try:
|
||||
info = client.get_collection(collection)
|
||||
return int(info.points_count or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
conn = _fallback_conn()
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM vectors WHERE collection=?", (collection,)
|
||||
).fetchone()
|
||||
return int(row[0] if row else 0)
|
||||
|
||||
|
||||
def search(
|
||||
collections: list[str],
|
||||
vector: list[float],
|
||||
limit: int = 8,
|
||||
visibility_filter: Optional[dict] = None,
|
||||
) -> list[dict]:
|
||||
results: list[dict] = []
|
||||
client = _get_qdrant()
|
||||
if client is not None:
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
for collection in collections:
|
||||
ensure_collection(collection, len(vector))
|
||||
flt = None
|
||||
if visibility_filter:
|
||||
must = [
|
||||
qm.FieldCondition(key=k, match=qm.MatchValue(value=v))
|
||||
for k, v in visibility_filter.items()
|
||||
]
|
||||
if must:
|
||||
flt = qm.Filter(must=must)
|
||||
hits = client.search(
|
||||
collection_name=collection,
|
||||
query_vector=vector,
|
||||
limit=limit,
|
||||
query_filter=flt,
|
||||
)
|
||||
for hit in hits:
|
||||
payload = dict(hit.payload or {})
|
||||
payload["score"] = hit.score
|
||||
payload["collection"] = collection
|
||||
results.append(payload)
|
||||
else:
|
||||
conn = _fallback_conn()
|
||||
for collection in collections:
|
||||
rows = conn.execute(
|
||||
"SELECT vector,payload FROM vectors WHERE collection=?", (collection,)
|
||||
).fetchall()
|
||||
for vec_raw, payload_raw in rows:
|
||||
payload = dict(json.loads(payload_raw))
|
||||
if visibility_filter:
|
||||
if any(payload.get(k) != v for k, v in visibility_filter.items()):
|
||||
continue
|
||||
score = _cosine(vector, json.loads(vec_raw))
|
||||
payload["score"] = score
|
||||
payload["collection"] = collection
|
||||
results.append(payload)
|
||||
results.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||
return results[:limit]
|
||||
@@ -1,22 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Utility condivise per chunking testo RAG."""
|
||||
|
||||
import re
|
||||
|
||||
CHUNK_SIZE = 900
|
||||
CHUNK_OVERLAP = 150
|
||||
|
||||
|
||||
def chunk_text(text: str) -> list[str]:
|
||||
text = re.sub(r"\n{3,}", "\n\n", text.strip())
|
||||
if len(text) <= CHUNK_SIZE:
|
||||
return [text] if text else []
|
||||
chunks = []
|
||||
start = 0
|
||||
while start < len(text):
|
||||
end = min(len(text), start + CHUNK_SIZE)
|
||||
chunks.append(text[start:end])
|
||||
if end >= len(text):
|
||||
break
|
||||
start = max(end - CHUNK_OVERLAP, start + 1)
|
||||
return chunks
|
||||
@@ -1,264 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Thermal / load gate per proteggere l'host degli embedding Ollama.
|
||||
|
||||
Legge temperatura CPU e load average (dell'host locale, pi1, oppure del DS920 via
|
||||
sonda HTTP/SSH) e regola pause/keep_alive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.thermal")
|
||||
|
||||
# Soft: rallenta. Hard: pausa (raro se il profilo lento tiene).
|
||||
# Profilo "lento ma regolare": anticipare soft, cap load basso.
|
||||
DEFAULT_SOFT_C = 58.0
|
||||
DEFAULT_HARD_C = 70.0
|
||||
DEFAULT_CPU_TARGET_PCT = 40.0 # load1 <= nproc * 0.40
|
||||
|
||||
|
||||
def _float_env(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.environ.get(name, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def soft_temp_c() -> float:
|
||||
return _float_env("THERMAL_TEMP_SOFT_C", DEFAULT_SOFT_C)
|
||||
|
||||
|
||||
def hard_temp_c() -> float:
|
||||
return _float_env("THERMAL_TEMP_HARD_C", DEFAULT_HARD_C)
|
||||
|
||||
|
||||
def cpu_target_pct() -> float:
|
||||
return _float_env("THERMAL_CPU_TARGET_PCT", DEFAULT_CPU_TARGET_PCT)
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
flag = os.environ.get("THERMAL_GATE_ENABLED", "yes").strip().lower()
|
||||
return flag not in ("0", "false", "no", "off")
|
||||
|
||||
|
||||
def _read_via_http() -> Optional[dict]:
|
||||
url = os.environ.get("DS920_THERMAL_URL", "").strip()
|
||||
if not url:
|
||||
# default probe se non configurato
|
||||
url = os.environ.get(
|
||||
"DS920_THERMAL_URL_DEFAULT",
|
||||
"http://192.168.128.100:9191/thermal",
|
||||
).strip()
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as client:
|
||||
resp = client.get(url)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
if isinstance(data, dict) and "cpu_temp_c" in data:
|
||||
return data
|
||||
except Exception as exc:
|
||||
LOGGER.debug("Thermal HTTP probe fallita: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _read_via_ssh() -> Optional[dict]:
|
||||
host = os.environ.get("DS920_SSH_HOST", "192.168.128.100").strip()
|
||||
user = os.environ.get("DS920_SSH_USER", "daniely").strip()
|
||||
key = os.environ.get("DS920_SSH_KEY", "").strip()
|
||||
if not host:
|
||||
return None
|
||||
remote = (
|
||||
"python3 -c \"import json,os;"
|
||||
"b='/sys/class/hwmon/hwmon0';"
|
||||
"t=[int(open(f'{b}/'+n).read())/1000 for n in sorted(os.listdir(b)) "
|
||||
"if n.startswith('temp') and n.endswith('_input')];"
|
||||
"l=os.getloadavg();"
|
||||
"print(json.dumps({'cpu_temp_c':max(t) if t else None,"
|
||||
"'load1':l[0],'load5':l[1],'nproc':os.cpu_count() or 4}))\""
|
||||
)
|
||||
cmd = [
|
||||
"ssh",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
]
|
||||
if key and os.path.isfile(key):
|
||||
cmd.extend(["-i", key])
|
||||
cmd.append(f"{user}@{host}")
|
||||
cmd.append(remote)
|
||||
try:
|
||||
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, timeout=12, text=True)
|
||||
data = json.loads(out.strip())
|
||||
if isinstance(data, dict) and data.get("cpu_temp_c") is not None:
|
||||
return data
|
||||
except Exception as exc:
|
||||
LOGGER.debug("Thermal SSH probe fallita: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _read_local() -> Optional[dict]:
|
||||
"""Temperatura e carico dell'host che esegue gli embedding (pi1): il container
|
||||
vede /sys/class/thermal e /proc/loadavg dell'host."""
|
||||
try:
|
||||
with open("/sys/class/thermal/thermal_zone0/temp", encoding="ascii") as f:
|
||||
temp = int(f.read().strip()) / 1000
|
||||
load1, load5, _ = os.getloadavg()
|
||||
return {"cpu_temp_c": temp, "load1": load1, "load5": load5, "nproc": os.cpu_count() or 4}
|
||||
except Exception as exc:
|
||||
LOGGER.debug("Thermal probe locale fallita: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def read_status() -> Optional[dict]:
|
||||
"""Ritorna {cpu_temp_c, load1, load5?, nproc} oppure None se non raggiungibile.
|
||||
|
||||
THERMAL_SOURCE=local legge l'host locale (embedding su pi1); default: DS920.
|
||||
"""
|
||||
if os.environ.get("THERMAL_SOURCE", "ds920").strip().lower() == "local":
|
||||
data = _read_local()
|
||||
if data:
|
||||
data["source"] = "local"
|
||||
return data
|
||||
data = _read_via_http()
|
||||
if data:
|
||||
data["source"] = "http"
|
||||
return data
|
||||
data = _read_via_ssh()
|
||||
if data:
|
||||
data["source"] = "ssh"
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def load_over_target(status: dict) -> bool:
|
||||
load1 = float(status.get("load1") or 0)
|
||||
nproc = float(status.get("nproc") or 4)
|
||||
target = nproc * (cpu_target_pct() / 100.0)
|
||||
return load1 > target
|
||||
|
||||
|
||||
def suggested_keep_alive(status: Optional[dict]) -> int:
|
||||
"""Secondi keep_alive Ollama.
|
||||
|
||||
Il container resta sempre acceso: non si fa unload per throttling termico
|
||||
(evita cicli load/unload). Si scarica solo se OLLAMA_UNLOAD_ON_HARD=yes.
|
||||
"""
|
||||
cool = int(_float_env("OLLAMA_KEEP_ALIVE_COOL", 300))
|
||||
default = int(_float_env("OLLAMA_KEEP_ALIVE_DEFAULT", 120))
|
||||
if not status or status.get("cpu_temp_c") is None:
|
||||
return default
|
||||
temp = float(status["cpu_temp_c"])
|
||||
unload = os.environ.get("OLLAMA_UNLOAD_ON_HARD", "no").strip().lower()
|
||||
if temp >= hard_temp_c() and unload in ("1", "true", "yes", "on"):
|
||||
return 0
|
||||
return cool
|
||||
|
||||
|
||||
def suggested_delay_s(status: Optional[dict]) -> float:
|
||||
"""Duty-cycle lento: delay base sempre presente; cresce con temp/load."""
|
||||
base = _float_env("OLLAMA_EMBED_DELAY_S", 8.0)
|
||||
if not status or status.get("cpu_temp_c") is None:
|
||||
return max(base, 5.0)
|
||||
temp = float(status["cpu_temp_c"])
|
||||
hard = hard_temp_c()
|
||||
soft = soft_temp_c()
|
||||
if temp >= hard:
|
||||
return max(base, 45.0)
|
||||
if temp >= soft:
|
||||
# soft→hard: ~base*1.5 … ~35s (continuo, non on/off)
|
||||
ratio = (temp - soft) / max(hard - soft, 1.0)
|
||||
return max(base, base * 1.5 + ratio * 25.0)
|
||||
if load_over_target(status):
|
||||
return max(base, base * 2.0)
|
||||
if temp >= soft - 4:
|
||||
return max(base, base * 1.25)
|
||||
return base
|
||||
|
||||
|
||||
def wait_for_headroom(*, context: str = "embed") -> Optional[dict]:
|
||||
"""Attende headroom: HARD = pausa lunga; altrimenti delay proporzionale.
|
||||
|
||||
Obiettivo: ritmo lento e regolare, evitando oscillazioni start/stop.
|
||||
"""
|
||||
if not enabled():
|
||||
return None
|
||||
|
||||
poll = _float_env("THERMAL_POLL_S", 30.0)
|
||||
hard = hard_temp_c()
|
||||
soft = soft_temp_c()
|
||||
# Riprendi solo quando sotto soft - 2°C (isteresi anti-oscillazione)
|
||||
resume_below = soft - _float_env("THERMAL_RESUME_MARGIN_C", 2.0)
|
||||
|
||||
while True:
|
||||
status = read_status()
|
||||
if status is None:
|
||||
LOGGER.warning("Thermal gate: probe non disponibile — delay conservativo")
|
||||
time.sleep(max(suggested_delay_s(None), 5.0))
|
||||
return None
|
||||
|
||||
temp = float(status.get("cpu_temp_c") or 0)
|
||||
load1 = float(status.get("load1") or 0)
|
||||
|
||||
if temp >= hard:
|
||||
LOGGER.warning(
|
||||
"Thermal gate [%s]: PAUSA HARD temp=%.1f°C (tetto=%.0f°C resume<=%.0f°C "
|
||||
"load1=%.2f) — riprovo tra %.0fs",
|
||||
context,
|
||||
temp,
|
||||
hard,
|
||||
resume_below,
|
||||
load1,
|
||||
poll,
|
||||
)
|
||||
time.sleep(poll)
|
||||
# Isteresi: resta in pausa finché non scende sotto soft
|
||||
while True:
|
||||
cooled = read_status()
|
||||
if cooled is None:
|
||||
time.sleep(poll)
|
||||
continue
|
||||
t2 = float(cooled.get("cpu_temp_c") or 0)
|
||||
if t2 <= resume_below and not load_over_target(cooled):
|
||||
LOGGER.info(
|
||||
"Thermal gate [%s]: ripresa dopo HARD (temp=%.1f°C)",
|
||||
context,
|
||||
t2,
|
||||
)
|
||||
status = cooled
|
||||
break
|
||||
time.sleep(poll)
|
||||
# dopo ripresa applica comunque un delay soft prima dell'embed
|
||||
time.sleep(suggested_delay_s(status))
|
||||
return status
|
||||
|
||||
if temp >= soft or load_over_target(status):
|
||||
delay = suggested_delay_s(status)
|
||||
LOGGER.info(
|
||||
"Thermal gate [%s]: rallento temp=%.1f°C load1=%.2f — delay %.1fs (source=%s)",
|
||||
context,
|
||||
temp,
|
||||
load1,
|
||||
delay,
|
||||
status.get("source"),
|
||||
)
|
||||
time.sleep(delay)
|
||||
again = read_status()
|
||||
if again and float(again.get("cpu_temp_c") or 0) >= hard:
|
||||
status = again
|
||||
continue
|
||||
return again or status
|
||||
|
||||
# Zona fredda: piccolo delay fisso per duty-cycle regolare
|
||||
cool_delay = _float_env("OLLAMA_EMBED_COOL_DELAY_S", 0.0)
|
||||
if cool_delay > 0:
|
||||
time.sleep(cool_delay)
|
||||
return status
|
||||
@@ -1,503 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Loogle MCP Hub — gateway FastAPI + OAuth + MCP Streamable HTTP."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
import anyio
|
||||
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from . import audit, auth, jwt_utils, oauth
|
||||
from .db import get_conn, init_db
|
||||
from .mcp import server as mcp_server
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
LOGGER = logging.getLogger("loogle_mcp.main")
|
||||
|
||||
app = FastAPI(title="Loogle MCP Hub", docs_url=None, redoc_url=None)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://claude.ai",
|
||||
"https://chatgpt.com",
|
||||
"https://chat.openai.com",
|
||||
],
|
||||
allow_methods=["GET", "POST", "HEAD", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||
# Thread per tool MCP ed endpoint sync: i tool fanno I/O bloccante (HTTP, SQLite, Ollama)
|
||||
MCP_THREAD_LIMIT = int(os.environ.get("MCP_THREAD_LIMIT", "40"))
|
||||
# Sotto il timeout dei client MCP: meglio un errore chiaro che una connessione appesa
|
||||
MCP_TOOL_TIMEOUT_S = float(os.environ.get("MCP_TOOL_TIMEOUT_S", "45"))
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
init_db()
|
||||
auth.ensure_sessions_table()
|
||||
auth.ensure_family_users()
|
||||
oauth.ensure_default_client()
|
||||
if not os.environ.get("MCP_JWT_SECRET", "").strip():
|
||||
secret = secrets.token_urlsafe(48)
|
||||
os.environ["MCP_JWT_SECRET"] = secret
|
||||
LOGGER.warning("MCP_JWT_SECRET generato — salvalo in .env: %s", secret)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def configure_threadpool() -> None:
|
||||
anyio.to_thread.current_default_thread_limiter().total_tokens = MCP_THREAD_LIMIT
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Health
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "service": "loogle-mcp", "port": int(os.environ.get("MCP_PORT", "8700"))}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ OAuth metadata
|
||||
|
||||
@app.get("/.well-known/oauth-authorization-server")
|
||||
def oauth_metadata():
|
||||
return oauth.authorization_server_metadata()
|
||||
|
||||
|
||||
@app.get("/.well-known/oauth-protected-resource")
|
||||
def protected_resource():
|
||||
return oauth.protected_resource_metadata()
|
||||
|
||||
|
||||
@app.get("/.well-known/oauth-protected-resource/mcp")
|
||||
def protected_resource_mcp():
|
||||
return oauth.protected_resource_metadata()
|
||||
|
||||
|
||||
@app.get("/.well-known/openid-configuration")
|
||||
def openid_configuration():
|
||||
"""Fallback discovery usato da Claude se oauth-authorization-server non basta."""
|
||||
return oauth.authorization_server_metadata()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ OAuth endpoints
|
||||
|
||||
class RegisterBody(BaseModel):
|
||||
client_name: str
|
||||
redirect_uris: list[str]
|
||||
|
||||
|
||||
@app.post("/oauth/register")
|
||||
def oauth_register(body: RegisterBody):
|
||||
try:
|
||||
return oauth.register_client(body.client_name, body.redirect_uris)
|
||||
except HTTPException as exc:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"error": "invalid_client_metadata", "error_description": str(exc.detail)},
|
||||
)
|
||||
|
||||
|
||||
# Alias root-level OAuth (Claude/ChatGPT fallback se la discovery RFC 8414 fallisce)
|
||||
@app.post("/register")
|
||||
def oauth_register_root(body: RegisterBody):
|
||||
return oauth_register(body)
|
||||
|
||||
|
||||
@app.get("/oauth/authorize")
|
||||
def oauth_authorize_get(
|
||||
response_type: str,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str = "context:read context:write knowledge:read knowledge:write gitea:read gitea:write",
|
||||
state: str = "",
|
||||
code_challenge: Optional[str] = None,
|
||||
code_challenge_method: Optional[str] = None,
|
||||
):
|
||||
if response_type != "code":
|
||||
raise HTTPException(400, "response_type must be code")
|
||||
html = _login_form(client_id, redirect_uri, scope, state, code_challenge, code_challenge_method)
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.post("/oauth/authorize")
|
||||
def oauth_authorize_post(
|
||||
request: Request,
|
||||
client_id: str = Form(...),
|
||||
redirect_uri: str = Form(...),
|
||||
scope: str = Form("context:read context:write knowledge:read knowledge:write gitea:read gitea:write"),
|
||||
state: str = Form(""),
|
||||
code_challenge: Optional[str] = Form(None),
|
||||
code_challenge_method: Optional[str] = Form(None),
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
ip = request.client.host if request.client else "?"
|
||||
auth.throttle(ip)
|
||||
user = auth.authenticate(username, password)
|
||||
if not user:
|
||||
auth.record_attempt(ip)
|
||||
html = _login_form(
|
||||
client_id, redirect_uri, scope, state, code_challenge, code_challenge_method,
|
||||
error="Credenziali non valide",
|
||||
)
|
||||
return HTMLResponse(html, status_code=401)
|
||||
url = oauth.build_authorize_redirect(
|
||||
client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, user["id"]
|
||||
)
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
@app.get("/authorize")
|
||||
def oauth_authorize_get_root(
|
||||
response_type: str,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str = "context:read context:write knowledge:read knowledge:write gitea:read gitea:write",
|
||||
state: str = "",
|
||||
code_challenge: Optional[str] = None,
|
||||
code_challenge_method: Optional[str] = None,
|
||||
):
|
||||
return oauth_authorize_get(
|
||||
response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method
|
||||
)
|
||||
|
||||
|
||||
@app.post("/authorize")
|
||||
def oauth_authorize_post_root(
|
||||
request: Request,
|
||||
client_id: str = Form(...),
|
||||
redirect_uri: str = Form(...),
|
||||
scope: str = Form("context:read context:write knowledge:read knowledge:write gitea:read gitea:write"),
|
||||
state: str = Form(""),
|
||||
code_challenge: Optional[str] = Form(None),
|
||||
code_challenge_method: Optional[str] = Form(None),
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
return oauth_authorize_post(
|
||||
request, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, username, password
|
||||
)
|
||||
|
||||
|
||||
@app.post("/oauth/token")
|
||||
async def oauth_token(request: Request):
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
body = await request.json()
|
||||
else:
|
||||
form = await request.form()
|
||||
body = dict(form)
|
||||
grant_type = body.get("grant_type")
|
||||
client_id = body.get("client_id") or os.environ.get("MCP_OAUTH_CLIENT_ID", "loogle-mcp-public")
|
||||
if grant_type == "authorization_code":
|
||||
return await run_in_threadpool(
|
||||
oauth.exchange_code,
|
||||
body.get("code", ""),
|
||||
client_id,
|
||||
body.get("redirect_uri", ""),
|
||||
body.get("code_verifier"),
|
||||
)
|
||||
if grant_type == "refresh_token":
|
||||
return await run_in_threadpool(
|
||||
oauth.refresh_access_token, body.get("refresh_token", ""), client_id
|
||||
)
|
||||
raise HTTPException(400, "grant_type non supportato")
|
||||
|
||||
|
||||
@app.post("/token")
|
||||
async def oauth_token_root(request: Request):
|
||||
return await oauth_token(request)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ MCP endpoint
|
||||
|
||||
_UNAUTHENTICATED_METHODS = ("initialize", "notifications/initialized", "ping")
|
||||
|
||||
|
||||
@app.post("/mcp")
|
||||
async def mcp_post(request: Request):
|
||||
header_version = request.headers.get("mcp-protocol-version")
|
||||
if header_version and header_version not in mcp_server.SUPPORTED_PROTOCOL_VERSIONS:
|
||||
# Non bloccare: Claude/Anthropic mandano versioni nuove; negoziamo nel body initialize
|
||||
LOGGER.warning(
|
||||
"MCP-Protocol-Version header=%s non in lista locale — continuo (negoziazione JSON-RPC)",
|
||||
header_version,
|
||||
)
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
claims = await run_in_threadpool(oauth.bearer_claims_from_header, auth_header)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "JSON non valido")
|
||||
messages = payload if isinstance(payload, list) else [payload]
|
||||
if not all(isinstance(msg, dict) for msg in messages) or not messages:
|
||||
return JSONResponse(
|
||||
{"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}},
|
||||
status_code=400,
|
||||
)
|
||||
if not any(mcp_server.expects_response(msg) for msg in messages):
|
||||
# Solo notifiche/response dal client: Streamable HTTP prevede 202 senza corpo
|
||||
if not claims and any(msg.get("method") not in _UNAUTHENTICATED_METHODS for msg in messages):
|
||||
return Response(status_code=401, headers=_auth_challenge_headers())
|
||||
return Response(status_code=202)
|
||||
if isinstance(payload, list):
|
||||
responses = await _run_with_timeout(mcp_server.handle_batch, payload, claims)
|
||||
if responses is None:
|
||||
responses = [_timeout_error(msg) for msg in payload if mcp_server.expects_response(msg)]
|
||||
return JSONResponse(responses)
|
||||
response = await _run_with_timeout(mcp_server.handle_message, payload, claims)
|
||||
if response is None:
|
||||
response = _timeout_error(payload)
|
||||
if not claims and payload.get("method") not in _UNAUTHENTICATED_METHODS:
|
||||
return JSONResponse(response, status_code=401, headers=_auth_challenge_headers())
|
||||
return JSONResponse(response)
|
||||
|
||||
|
||||
async def _run_with_timeout(fn, payload, claims):
|
||||
"""I tool sono sincroni e bloccanti: threadpool per non fermare l'event loop.
|
||||
|
||||
Allo scadere ritorna None; il thread prosegue fino alla fine ma il client è già servito.
|
||||
"""
|
||||
try:
|
||||
return await asyncio.wait_for(run_in_threadpool(fn, payload, claims), MCP_TOOL_TIMEOUT_S)
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
|
||||
|
||||
def _timeout_error(msg: dict) -> dict:
|
||||
params = msg.get("params") or {}
|
||||
target = params.get("name") or msg.get("method")
|
||||
LOGGER.warning("MCP %s oltre %.0fs: risposta di timeout al client", target, MCP_TOOL_TIMEOUT_S)
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg.get("id"),
|
||||
"error": {
|
||||
"code": -32000,
|
||||
"message": f"Timeout: '{target}' non ha risposto entro {MCP_TOOL_TIMEOUT_S:.0f}s "
|
||||
"(backend lento o non raggiungibile). Riprova tra poco.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/")
|
||||
async def root_mcp_post(request: Request):
|
||||
"""Alcuni client (Claude) dopo OAuth POST-ano sulla root invece che su /mcp."""
|
||||
ctype = (request.headers.get("content-type") or "").lower()
|
||||
accept = (request.headers.get("accept") or "").lower()
|
||||
if "json" not in ctype and "json" not in accept and "event-stream" not in accept:
|
||||
raise HTTPException(404, "Not Found")
|
||||
return await mcp_post(request)
|
||||
|
||||
|
||||
@app.api_route("/mcp", methods=["GET", "HEAD"])
|
||||
async def mcp_get(request: Request):
|
||||
"""Streamable HTTP: GET può aprire SSE; HEAD serve alla discovery di Claude."""
|
||||
if request.method == "HEAD":
|
||||
return Response(
|
||||
status_code=200,
|
||||
headers={
|
||||
**_auth_challenge_headers(),
|
||||
"Allow": "GET, HEAD, POST, OPTIONS",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
accept = (request.headers.get("accept") or "").lower()
|
||||
if "text/event-stream" in accept:
|
||||
# Nessun messaggio server-initiated: stream SSE vuoto che si chiude subito
|
||||
async def _empty():
|
||||
yield ": loogle-mcp ready\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
_empty(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
**_auth_challenge_headers(),
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"service": "loogle-mcp",
|
||||
"transport": "streamable-http",
|
||||
"endpoint": "/mcp",
|
||||
"hint": "Usa POST JSON-RPC (initialize, tools/list, tools/call)",
|
||||
},
|
||||
headers=_auth_challenge_headers(),
|
||||
)
|
||||
|
||||
|
||||
@app.api_route("/mcp/", methods=["GET", "HEAD", "POST", "OPTIONS"])
|
||||
async def mcp_slash(request: Request):
|
||||
"""Evita redirect HTTP su /mcp/ (NPM) che rompe Claude."""
|
||||
if request.method == "POST":
|
||||
return await mcp_post(request)
|
||||
if request.method == "OPTIONS":
|
||||
return Response(
|
||||
status_code=204,
|
||||
headers={
|
||||
"Allow": "GET, HEAD, POST, OPTIONS",
|
||||
"Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Authorization, Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id",
|
||||
},
|
||||
)
|
||||
return await mcp_get(request)
|
||||
|
||||
|
||||
@app.options("/mcp")
|
||||
async def mcp_options():
|
||||
return Response(
|
||||
status_code=204,
|
||||
headers={
|
||||
"Allow": "GET, HEAD, POST, OPTIONS",
|
||||
"Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Authorization, Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _auth_challenge_headers() -> dict:
|
||||
base = jwt_utils.base_url()
|
||||
resource_metadata = f"{base}/.well-known/oauth-protected-resource/mcp"
|
||||
return {
|
||||
"WWW-Authenticate": f'Bearer realm="mcp", resource_metadata="{resource_metadata}"',
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Dashboard web
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class PasswordBody(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class RevokeBody(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
@app.post("/api/login")
|
||||
def api_login(body: LoginBody, request: Request, response: Response):
|
||||
ip = request.client.host if request.client else "?"
|
||||
auth.throttle(ip)
|
||||
user = auth.authenticate(body.username.strip(), body.password)
|
||||
if not user:
|
||||
auth.record_attempt(ip)
|
||||
raise HTTPException(401, "Credenziali non valide")
|
||||
import datetime
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires = (
|
||||
datetime.datetime.utcnow() + datetime.timedelta(days=auth.SESSION_DAYS)
|
||||
).strftime("%Y-%m-%d %H:%M:%S")
|
||||
get_conn().execute(
|
||||
"INSERT INTO sessions(token,user_id,expires_at) VALUES (?,?,?)",
|
||||
(token, user["id"], expires),
|
||||
)
|
||||
get_conn().commit()
|
||||
response.set_cookie("mcp_session", token, max_age=auth.SESSION_DAYS * 86400, httponly=True, samesite="lax", path="/")
|
||||
return {"ok": True, "user": {"username": user["username"], "is_admin": user["is_admin"]}}
|
||||
|
||||
|
||||
@app.post("/api/logout")
|
||||
def api_logout(request: Request, response: Response):
|
||||
token = request.cookies.get("mcp_session", "")
|
||||
if token:
|
||||
get_conn().execute("DELETE FROM sessions WHERE token=?", (token,))
|
||||
get_conn().commit()
|
||||
response.delete_cookie("mcp_session", path="/")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/me")
|
||||
def api_me(user=Depends(auth.current_user_from_cookie)):
|
||||
return user
|
||||
|
||||
|
||||
@app.post("/api/password")
|
||||
def api_password(body: PasswordBody, user=Depends(auth.current_user_from_cookie)):
|
||||
if len(body.new_password.strip()) < 6:
|
||||
raise HTTPException(400, "La nuova password deve avere almeno 6 caratteri")
|
||||
if not auth.change_password(user["id"], body.old_password, body.new_password):
|
||||
raise HTTPException(400, "Password attuale errata")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
def api_projects(user=Depends(auth.current_user_from_cookie)):
|
||||
from .context import store as context_store
|
||||
return context_store.list_projects(user["username"])
|
||||
|
||||
|
||||
@app.get("/api/audit")
|
||||
def api_audit(limit: int = 100, user=Depends(auth.current_user_from_cookie)):
|
||||
if user["is_admin"]:
|
||||
return audit.list_audit(limit=min(limit, 500))
|
||||
return audit.list_audit(limit=min(limit, 200), username=user["username"])
|
||||
|
||||
|
||||
@app.post("/api/admin/revoke-refresh")
|
||||
def api_revoke_refresh(body: RevokeBody, _=Depends(auth.require_admin)):
|
||||
jwt_utils.revoke_refresh_token(body.refresh_token)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
def dashboard_page():
|
||||
path = os.path.join(STATIC_DIR, "dashboard.html")
|
||||
return HTMLResponse(open(path, encoding="utf-8").read())
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return RedirectResponse("/dashboard")
|
||||
|
||||
|
||||
def _login_form(
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str,
|
||||
state: str,
|
||||
code_challenge: Optional[str],
|
||||
code_challenge_method: Optional[str],
|
||||
error: str = "",
|
||||
) -> str:
|
||||
err = f'<p class="error">{error}</p>' if error else ""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Loogle MCP — Login</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;max-width:420px;margin:4rem auto;padding:1rem;background:#0f172a;color:#e2e8f0}}
|
||||
h1{{font-size:1.4rem}} .card{{background:#1e293b;padding:1.5rem;border-radius:12px}}
|
||||
label{{display:block;margin:.75rem 0 .25rem}} input{{width:100%;padding:.5rem;border-radius:6px;border:1px solid #334155;background:#0f172a;color:#e2e8f0}}
|
||||
button{{margin-top:1rem;width:100%;padding:.65rem;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer}}
|
||||
.error{{color:#f87171}} .hint{{font-size:.85rem;color:#94a3b8;margin-top:1rem}}
|
||||
</style></head><body>
|
||||
<h1>Loogle MCP Hub</h1>
|
||||
<p>Accedi con le credenziali famiglia per collegare Claude, ChatGPT o Gemini.</p>
|
||||
<div class="card">{err}
|
||||
<form method="post" action="/authorize">
|
||||
<input type="hidden" name="client_id" value="{client_id}">
|
||||
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
|
||||
<input type="hidden" name="scope" value="{scope}">
|
||||
<input type="hidden" name="state" value="{state}">
|
||||
<input type="hidden" name="code_challenge" value="{code_challenge or ''}">
|
||||
<input type="hidden" name="code_challenge_method" value="{code_challenge_method or ''}">
|
||||
<label>Utente</label><input name="username" autocomplete="username" required>
|
||||
<label>Password</label><input name="password" type="password" autocomplete="current-password" required>
|
||||
<button type="submit">Autorizza accesso MCP</button>
|
||||
</form>
|
||||
<p class="hint">Primo accesso: password = username (es. lucia/lucia). Cambiala dal dashboard.</p>
|
||||
</div></body></html>"""
|
||||
Whitespace-only changes.
@@ -1,97 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""MCP JSON-RPC handler (Streamable HTTP compatible)."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from . import tools
|
||||
|
||||
LOGGER = logging.getLogger("loogle_mcp.server")
|
||||
# Versioni supportate, dalla più recente: si risponde con quella chiesta dal client
|
||||
# se supportata, altrimenti con la più recente (negoziazione MCP lifecycle)
|
||||
SUPPORTED_PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
||||
|
||||
|
||||
def negotiate_protocol_version(requested: Optional[str]) -> str:
|
||||
if requested in SUPPORTED_PROTOCOL_VERSIONS:
|
||||
return requested
|
||||
# Client più nuovi: accettiamo la richiesta dichiarando la più recente che implementiamo
|
||||
if requested:
|
||||
LOGGER.info("protocollo sconosciuto %s — negozio %s", requested, SUPPORTED_PROTOCOL_VERSIONS[0])
|
||||
return SUPPORTED_PROTOCOL_VERSIONS[0]
|
||||
|
||||
|
||||
def expects_response(msg: Any) -> bool:
|
||||
"""Solo le request JSON-RPC (method + id) hanno risposta; notifiche e response no."""
|
||||
return isinstance(msg, dict) and "method" in msg and "id" in msg
|
||||
|
||||
|
||||
def _error(req_id: Any, code: int, message: str) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
def _result(req_id: Any, result: dict) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
||||
|
||||
|
||||
def handle_message(body: dict, claims: Optional[dict]) -> dict:
|
||||
method = body.get("method")
|
||||
req_id = body.get("id")
|
||||
params = body.get("params") or {}
|
||||
|
||||
if method == "initialize":
|
||||
requested = params.get("protocolVersion")
|
||||
version = negotiate_protocol_version(requested)
|
||||
LOGGER.info(
|
||||
"initialize: client=%s protocollo richiesto=%s negoziato=%s",
|
||||
(params.get("clientInfo") or {}).get("name"), requested, version,
|
||||
)
|
||||
return _result(
|
||||
req_id,
|
||||
{
|
||||
"protocolVersion": version,
|
||||
"capabilities": {"tools": {}, "resources": {}},
|
||||
"serverInfo": {"name": "loogle-mcp", "version": "1.0.0"},
|
||||
},
|
||||
)
|
||||
|
||||
if method == "ping":
|
||||
return _result(req_id, {})
|
||||
|
||||
if not claims:
|
||||
return _error(req_id, -32001, "Autenticazione richiesta (Bearer token OAuth)")
|
||||
|
||||
if method == "tools/list":
|
||||
return _result(req_id, {"tools": tools.tool_definitions()})
|
||||
|
||||
if method == "tools/call":
|
||||
name = params.get("name")
|
||||
arguments = params.get("arguments") or {}
|
||||
try:
|
||||
tool_result = tools.call_tool(name, arguments, claims)
|
||||
return _result(req_id, tool_result)
|
||||
except PermissionError as exc:
|
||||
return _error(req_id, -32003, str(exc))
|
||||
except FileNotFoundError as exc:
|
||||
return _error(req_id, -32004, str(exc))
|
||||
except Exception as exc:
|
||||
LOGGER.exception("Tool %s failed", name)
|
||||
return _error(req_id, -32000, str(exc))
|
||||
|
||||
if method == "resources/list":
|
||||
return _result(req_id, {"resources": tools.list_resources(claims)})
|
||||
|
||||
if method == "resources/read":
|
||||
uri = params.get("uri")
|
||||
try:
|
||||
resource = tools.read_resource(uri, claims)
|
||||
return _result(req_id, {"contents": [resource]})
|
||||
except FileNotFoundError as exc:
|
||||
return _error(req_id, -32004, str(exc))
|
||||
|
||||
return _error(req_id, -32601, f"Metodo non supportato: {method}")
|
||||
|
||||
|
||||
def handle_batch(messages: list, claims: Optional[dict]) -> list:
|
||||
return [handle_message(msg, claims) for msg in messages if expects_response(msg)]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,322 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""OAuth 2.1 Authorization Code + PKCE."""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from . import auth
|
||||
from .db import get_conn
|
||||
from .jwt_utils import (
|
||||
ACCESS_TOKEN_HOURS,
|
||||
base_url,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_access_token,
|
||||
revoke_refresh_token,
|
||||
scopes_for_user,
|
||||
consume_refresh_token,
|
||||
)
|
||||
|
||||
|
||||
def _pkce_valid(code_verifier: str, challenge: str, method: str) -> bool:
|
||||
if method != "S256":
|
||||
return False
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
computed = base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
return computed == challenge
|
||||
|
||||
|
||||
TRUSTED_REDIRECT_URIS = frozenset({
|
||||
"https://claude.ai/api/mcp/auth_callback",
|
||||
"https://chatgpt.com/connector_platform_oauth_redirect",
|
||||
"https://chat.openai.com/connector_platform_oauth_redirect",
|
||||
# Cursor IDE / Agents (docs.cursor.com/mcp)
|
||||
"https://www.cursor.com/agents/mcp/oauth/callback",
|
||||
"http://localhost:8787/callback",
|
||||
# Legacy Cursor desktop
|
||||
"cursor://anysphere.cursor-mcp/oauth/callback",
|
||||
})
|
||||
|
||||
|
||||
def ensure_default_client() -> None:
|
||||
clients = [
|
||||
(
|
||||
os.environ.get("MCP_OAUTH_CLIENT_ID", "loogle-mcp-public"),
|
||||
"Loogle MCP Public",
|
||||
[
|
||||
"https://chatgpt.com/connector_platform_oauth_redirect",
|
||||
"https://chat.openai.com/connector_platform_oauth_redirect",
|
||||
"https://claude.ai/api/mcp/auth_callback",
|
||||
"https://www.cursor.com/agents/mcp/oauth/callback",
|
||||
"http://localhost:8787/callback",
|
||||
"cursor://anysphere.cursor-mcp/oauth/callback",
|
||||
"http://127.0.0.1:*/callback",
|
||||
"http://localhost:*/callback",
|
||||
],
|
||||
),
|
||||
(
|
||||
"cursor",
|
||||
"Cursor IDE",
|
||||
[
|
||||
"https://www.cursor.com/agents/mcp/oauth/callback",
|
||||
"http://localhost:8787/callback",
|
||||
"cursor://anysphere.cursor-mcp/oauth/callback",
|
||||
],
|
||||
),
|
||||
(
|
||||
"claude-desktop",
|
||||
"Claude Desktop/App",
|
||||
["https://claude.ai/api/mcp/auth_callback"],
|
||||
),
|
||||
(
|
||||
"claude-ai",
|
||||
"Claude.ai",
|
||||
["https://claude.ai/api/mcp/auth_callback"],
|
||||
),
|
||||
]
|
||||
conn = get_conn()
|
||||
for client_id, client_name, redirect_uris in clients:
|
||||
row = conn.execute(
|
||||
"SELECT client_id FROM oauth_clients WHERE client_id=?", (client_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute(
|
||||
"UPDATE oauth_clients SET client_name=?, redirect_uris=? WHERE client_id=?",
|
||||
(client_name, json.dumps(redirect_uris), client_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)",
|
||||
(client_id, client_name, json.dumps(redirect_uris)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _is_trusted_redirect_uri(redirect_uri: str) -> bool:
|
||||
if redirect_uri in TRUSTED_REDIRECT_URIS:
|
||||
return True
|
||||
parsed = urlparse(redirect_uri)
|
||||
if parsed.scheme == "http" and parsed.hostname in ("127.0.0.1", "localhost"):
|
||||
if (parsed.path or "").endswith("/callback"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def register_client(client_name: str, redirect_uris: list[str]) -> dict:
|
||||
client_id = secrets.token_urlsafe(16)
|
||||
for uri in redirect_uris:
|
||||
if not _is_trusted_redirect_uri(uri):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Redirect URI non consentito: {uri}",
|
||||
)
|
||||
get_conn().execute(
|
||||
"INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)",
|
||||
(client_id, client_name, json.dumps(redirect_uris)),
|
||||
)
|
||||
get_conn().commit()
|
||||
return {"client_id": client_id, "client_name": client_name, "redirect_uris": redirect_uris}
|
||||
|
||||
|
||||
def _client_redirect_uris(client_id: str) -> list[str]:
|
||||
row = get_conn().execute(
|
||||
"SELECT redirect_uris FROM oauth_clients WHERE client_id=?", (client_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return []
|
||||
return json.loads(row["redirect_uris"])
|
||||
|
||||
|
||||
def _ensure_client_for_redirect(client_id: str, redirect_uri: str) -> None:
|
||||
"""Registra client OAuth al volo (Claude usa spesso client_id = username)."""
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT redirect_uris FROM oauth_clients WHERE client_id=?", (client_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
uris = set(json.loads(row["redirect_uris"]))
|
||||
if redirect_uri not in uris:
|
||||
uris.add(redirect_uri)
|
||||
conn.execute(
|
||||
"UPDATE oauth_clients SET redirect_uris=? WHERE client_id=?",
|
||||
(json.dumps(sorted(uris)), client_id),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)",
|
||||
(client_id, f"MCP client {client_id}", json.dumps([redirect_uri])),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _redirect_allowed(client_id: str, redirect_uri: str) -> bool:
|
||||
if _is_trusted_redirect_uri(redirect_uri):
|
||||
_ensure_client_for_redirect(client_id, redirect_uri)
|
||||
return True
|
||||
allowed = _client_redirect_uris(client_id)
|
||||
if redirect_uri in allowed:
|
||||
return True
|
||||
parsed = urlparse(redirect_uri)
|
||||
for pattern in allowed:
|
||||
if "*" in pattern:
|
||||
pp = urlparse(pattern.replace("*", "placeholder"))
|
||||
if parsed.scheme == pp.scheme and parsed.netloc.endswith(pp.netloc.split("placeholder")[-1]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def create_auth_code(
|
||||
client_id: str,
|
||||
user_id: int,
|
||||
redirect_uri: str,
|
||||
scope: str,
|
||||
code_challenge: Optional[str],
|
||||
code_challenge_method: Optional[str],
|
||||
) -> str:
|
||||
code = secrets.token_urlsafe(32)
|
||||
expires = (
|
||||
datetime.datetime.utcnow() + datetime.timedelta(minutes=10)
|
||||
).strftime("%Y-%m-%d %H:%M:%S")
|
||||
get_conn().execute(
|
||||
"INSERT INTO oauth_codes(code,client_id,user_id,redirect_uri,scope,code_challenge,code_challenge_method,expires_at)"
|
||||
" VALUES (?,?,?,?,?,?,?,?)",
|
||||
(code, client_id, user_id, redirect_uri, scope, code_challenge, code_challenge_method, expires),
|
||||
)
|
||||
get_conn().commit()
|
||||
return code
|
||||
|
||||
|
||||
def exchange_code(
|
||||
code: str,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
code_verifier: Optional[str],
|
||||
) -> dict:
|
||||
row = get_conn().execute(
|
||||
"SELECT * FROM oauth_codes WHERE code=? AND used=0 AND expires_at > datetime('now')",
|
||||
(code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(400, "Codice non valido o scaduto")
|
||||
row = dict(row)
|
||||
if row["client_id"] != client_id or row["redirect_uri"] != redirect_uri:
|
||||
raise HTTPException(400, "Client o redirect URI non validi")
|
||||
if row.get("code_challenge"):
|
||||
if not code_verifier or not _pkce_valid(code_verifier, row["code_challenge"], row.get("code_challenge_method") or "S256"):
|
||||
raise HTTPException(400, "PKCE verification failed")
|
||||
user = auth.get_user_by_id(row["user_id"])
|
||||
if not user:
|
||||
raise HTTPException(400, "Utente non trovato")
|
||||
get_conn().execute("UPDATE oauth_codes SET used=1 WHERE code=?", (code,))
|
||||
get_conn().commit()
|
||||
scope = scopes_for_user(user, row["scope"])
|
||||
access_token, _ = create_access_token(user, scope, client_id)
|
||||
refresh = create_refresh_token(user["id"], scope, client_id)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ACCESS_TOKEN_HOURS * 3600,
|
||||
"refresh_token": refresh,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
|
||||
def refresh_access_token(refresh_token: str, client_id: str) -> dict:
|
||||
row = consume_refresh_token(refresh_token)
|
||||
if not row or row["client_id"] != client_id:
|
||||
raise HTTPException(400, "Refresh token non valido")
|
||||
user = auth.get_user_by_id(row["user_id"])
|
||||
if not user:
|
||||
raise HTTPException(400, "Utente non trovato")
|
||||
revoke_refresh_token(refresh_token)
|
||||
scope = row["scope"]
|
||||
access_token, _ = create_access_token(user, scope, client_id)
|
||||
refresh = create_refresh_token(user["id"], scope, client_id)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ACCESS_TOKEN_HOURS * 3600,
|
||||
"refresh_token": refresh,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
|
||||
def authorization_server_metadata() -> dict:
|
||||
base = base_url()
|
||||
return {
|
||||
"issuer": base,
|
||||
"authorization_endpoint": f"{base}/authorize",
|
||||
"token_endpoint": f"{base}/token",
|
||||
"registration_endpoint": f"{base}/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
|
||||
"scopes_supported": [
|
||||
"context:read",
|
||||
"context:write",
|
||||
"knowledge:read",
|
||||
"knowledge:write",
|
||||
"gitea:read",
|
||||
"gitea:write",
|
||||
"home:read",
|
||||
"irrigation:read",
|
||||
"turni:read",
|
||||
"admin",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def protected_resource_metadata() -> dict:
|
||||
base = base_url()
|
||||
return {
|
||||
"resource": f"{base}/mcp",
|
||||
"authorization_servers": [base],
|
||||
"scopes_supported": [
|
||||
"context:read",
|
||||
"context:write",
|
||||
"knowledge:read",
|
||||
"knowledge:write",
|
||||
"gitea:read",
|
||||
"gitea:write",
|
||||
"home:read",
|
||||
"irrigation:read",
|
||||
"turni:read",
|
||||
],
|
||||
"bearer_methods_supported": ["header"],
|
||||
}
|
||||
|
||||
|
||||
def build_authorize_redirect(
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str,
|
||||
state: str,
|
||||
code_challenge: Optional[str],
|
||||
code_challenge_method: Optional[str],
|
||||
user_id: int,
|
||||
) -> str:
|
||||
if not _redirect_allowed(client_id, redirect_uri):
|
||||
raise HTTPException(400, "Redirect URI non autorizzato")
|
||||
code = create_auth_code(
|
||||
client_id, user_id, redirect_uri, scope, code_challenge, code_challenge_method
|
||||
)
|
||||
params = {"code": code, "state": state}
|
||||
sep = "&" if "?" in redirect_uri else "?"
|
||||
return f"{redirect_uri}{sep}{urlencode(params)}"
|
||||
|
||||
|
||||
def bearer_claims_from_header(authorization: str) -> Optional[dict]:
|
||||
if not authorization.lower().startswith("bearer "):
|
||||
return None
|
||||
token = authorization[7:].strip()
|
||||
return decode_access_token(token)
|
||||
@@ -1,129 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Loogle MCP Dashboard</title>
|
||||
<style>
|
||||
:root { --bg:#0f172a; --card:#1e293b; --text:#e2e8f0; --muted:#94a3b8; --accent:#2563eb; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 1rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: .25rem; }
|
||||
.sub { color: var(--muted); margin-bottom: 1.5rem; }
|
||||
.card { background: var(--card); border-radius: 12px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
||||
button { background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: .55rem 1rem; cursor: pointer; }
|
||||
button.secondary { background: #334155; }
|
||||
input { width: 100%; padding: .5rem; border-radius: 6px; border: 1px solid #334155; background: var(--bg); color: var(--text); margin: .35rem 0 .75rem; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
||||
th, td { text-align: left; padding: .45rem .25rem; border-bottom: 1px solid #334155; }
|
||||
.hidden { display: none; }
|
||||
.error { color: #f87171; }
|
||||
code { background: #0b1220; padding: .15rem .35rem; border-radius: 4px; font-size: .85rem; }
|
||||
ul { padding-left: 1.2rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Loogle MCP Hub</h1>
|
||||
<p class="sub">Archivio contesto e knowledge base locale per AI famiglia</p>
|
||||
|
||||
<div id="loginView" class="card">
|
||||
<h2>Accedi</h2>
|
||||
<label>Utente</label>
|
||||
<input id="loginUser" autocomplete="username">
|
||||
<label>Password</label>
|
||||
<input id="loginPass" type="password" autocomplete="current-password">
|
||||
<button onclick="doLogin()">Entra</button>
|
||||
<p id="loginErr" class="error hidden"></p>
|
||||
</div>
|
||||
|
||||
<div id="appView" class="hidden">
|
||||
<div class="card">
|
||||
<strong id="welcome"></strong>
|
||||
<button class="secondary" onclick="doLogout()" style="float:right">Esci</button>
|
||||
<p>MCP URL: <code>https://mcp.loogle.it/mcp</code></p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="pwdCard">
|
||||
<h3>Cambia password</h3>
|
||||
<label>Password attuale</label><input id="oldPwd" type="password">
|
||||
<label>Nuova password</label><input id="newPwd" type="password">
|
||||
<button onclick="changePwd()">Salva</button>
|
||||
<p id="pwdMsg"></p>
|
||||
</div>
|
||||
|
||||
<div class="card hidden" id="adminCard">
|
||||
<h3>Admin — revoca refresh token</h3>
|
||||
<label>Refresh token</label><input id="revokeToken">
|
||||
<button onclick="revokeRefresh()">Revoca</button>
|
||||
<p id="revokeMsg"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Progetti</h3>
|
||||
<div id="projects"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Audit log recente</h3>
|
||||
<table><thead><tr><th>Ora</th><th>Utente</th><th>Tool</th><th>Risorsa</th></tr></thead><tbody id="auditBody"></tbody></table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Collegamenti AI</h3>
|
||||
<ul>
|
||||
<li><strong>ChatGPT:</strong> Impostazioni → Developer → Add MCP Connector → URL sopra</li>
|
||||
<li><strong>Claude:</strong> Settings → Connectors → Add remote MCP server</li>
|
||||
<li><strong>Gemini CLI:</strong> configura server remoto in settings MCP</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function api(path, opts={}) {
|
||||
const r = await fetch(path, { credentials: 'same-origin', headers: {'Content-Type':'application/json', ...(opts.headers||{})}, ...opts });
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.json();
|
||||
}
|
||||
function show(el, on) { document.getElementById(el).classList.toggle('hidden', !on); }
|
||||
async function boot() {
|
||||
try {
|
||||
const me = await api('/api/me');
|
||||
show('loginView', false); show('appView', true);
|
||||
document.getElementById('welcome').textContent = 'Ciao ' + me.username + (me.is_admin ? ' (admin)' : '');
|
||||
if (me.is_admin) document.getElementById('adminCard').classList.remove('hidden');
|
||||
loadProjects(); loadAudit();
|
||||
} catch { show('loginView', true); show('appView', false); }
|
||||
}
|
||||
async function doLogin() {
|
||||
try {
|
||||
await api('/api/login', { method:'POST', body: JSON.stringify({ username: loginUser.value, password: loginPass.value }) });
|
||||
boot();
|
||||
} catch (e) {
|
||||
loginErr.textContent = 'Credenziali non valide'; loginErr.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
async function doLogout() { await api('/api/logout', { method:'POST', body:'{}' }); boot(); }
|
||||
async function changePwd() {
|
||||
try {
|
||||
await api('/api/password', { method:'POST', body: JSON.stringify({ old_password: oldPwd.value, new_password: newPwd.value }) });
|
||||
pwdMsg.textContent = 'Password aggiornata';
|
||||
} catch (e) { pwdMsg.textContent = 'Errore: password attuale errata'; }
|
||||
}
|
||||
async function loadProjects() {
|
||||
const rows = await api('/api/projects');
|
||||
projects.innerHTML = rows.length ? rows.map(p => `<div><strong>${p.title}</strong> <small>${p.id}</small> — agg. ${p.updated_at||p.created_at}</div>`).join('') : '<em>Nessun progetto</em>';
|
||||
}
|
||||
async function loadAudit() {
|
||||
const rows = await api('/api/audit?limit=30');
|
||||
auditBody.innerHTML = rows.map(r => `<tr><td>${r.created_at}</td><td>${r.username}</td><td>${r.tool_name}</td><td>${r.resource_id||''}</td></tr>`).join('');
|
||||
}
|
||||
async function revokeRefresh() {
|
||||
try {
|
||||
await api('/api/admin/revoke-refresh', { method:'POST', body: JSON.stringify({ refresh_token: revokeToken.value }) });
|
||||
revokeMsg.textContent = 'Token revocato';
|
||||
} catch (e) { revokeMsg.textContent = 'Errore revoca'; }
|
||||
}
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
drill-20261031-205709 C0
|
||||
@@ -1 +0,0 @@
|
||||
drill-20261031-205709 C1
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Whitespace-only changes.
Binary file not shown.
@@ -1,103 +0,0 @@
|
||||
# Embedding (query + indicizzazione) su Ollama locale di pi1: il DS920 (J4125, senza AVX)
|
||||
# impiega minuti per chunk. Sul DS920 restano Qdrant (base di conoscenza) e un Ollama di
|
||||
# riserva per le sole query (OLLAMA_URL in .env). I valori qui sovrascrivono .env.
|
||||
x-embedding-env: &embedding-env
|
||||
OLLAMA_QUERY_URL: http://ollama:11434
|
||||
OLLAMA_INDEX_URL: http://ollama:11434
|
||||
OLLAMA_QUERY_NUM_THREAD: "2"
|
||||
# Thermal gate sull'host locale (pi1), soglie da Pi5 (throttling a 80-85°C)
|
||||
THERMAL_SOURCE: local
|
||||
THERMAL_TEMP_SOFT_C: "70"
|
||||
THERMAL_TEMP_HARD_C: "78"
|
||||
THERMAL_CPU_TARGET_PCT: "75"
|
||||
THERMAL_POLL_S: "15"
|
||||
OLLAMA_EMBED_DELAY_S: "0.5"
|
||||
OLLAMA_EMBED_COOL_DELAY_S: "0"
|
||||
|
||||
services:
|
||||
loogle-mcp:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: loogle-mcp:latest
|
||||
container_name: loogle-mcp
|
||||
restart: always
|
||||
ports:
|
||||
- "8700:8700"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *embedding-env
|
||||
TZ: Europe/Rome
|
||||
MCP_DB: /data/loogle_mcp.db
|
||||
MCP_CONTEXT_ROOT: /data/context
|
||||
MCP_AUDIT_LOG: /data/audit.log
|
||||
MCP_VECTOR_FALLBACK: /data/vector_fallback.db
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- /mnt/ha-apps/mcp/context:/data/context
|
||||
depends_on:
|
||||
- ollama
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8700/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
loogle-mcp-indexer:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: loogle-mcp:latest
|
||||
container_name: loogle-mcp-indexer
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *embedding-env
|
||||
TZ: Europe/Rome
|
||||
MCP_DB: /data/loogle_mcp.db
|
||||
MCP_CONTEXT_ROOT: /data/context
|
||||
MCP_VECTOR_FALLBACK: /data/vector_fallback.db
|
||||
command: ["python", "-m", "worker.indexer_main"]
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- /mnt/ha-apps/mcp/context:/data/context
|
||||
depends_on:
|
||||
- loogle-mcp
|
||||
- ollama
|
||||
|
||||
# Ollama per gli embedding (stesso modello del DS920 → stesso spazio vettoriale).
|
||||
# Nessuna porta pubblicata: raggiungibile solo dai servizi MCP sulla rete compose.
|
||||
ollama:
|
||||
image: ollama/ollama:0.32.15
|
||||
container_name: loogle-mcp-ollama
|
||||
restart: always
|
||||
environment:
|
||||
- OLLAMA_KEEP_ALIVE=-1
|
||||
- OLLAMA_NUM_PARALLEL=2
|
||||
- OLLAMA_MAX_LOADED_MODELS=1
|
||||
volumes:
|
||||
# Volume Docker (non bind nella cartella progetto: il sorgente viene copiato su Gitea)
|
||||
- ollama-models:/root/.ollama
|
||||
cpus: 2
|
||||
mem_limit: 1g
|
||||
healthcheck:
|
||||
test: ["CMD", "ollama", "list"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Qdrant locale: solo su x86/DS920 — su Pi5 ARM 16K page size non supportato
|
||||
qdrant-local:
|
||||
profiles: ["qdrant-local"]
|
||||
image: qdrant/qdrant:v1.13.2
|
||||
container_name: loogle_mcp_qdrant
|
||||
restart: always
|
||||
ports:
|
||||
- "6333:6333"
|
||||
volumes:
|
||||
- /mnt/ha-apps/mcp/qdrant:/qdrant/storage
|
||||
|
||||
volumes:
|
||||
ollama-models:
|
||||
@@ -1,91 +0,0 @@
|
||||
# Integrazione app homelab — P5/P6/P7
|
||||
|
||||
Tool live e RAG su **Loogle Casa**, **Home Assistant**, **Irrigazione** e **Turni**.
|
||||
|
||||
---
|
||||
|
||||
## P5 — Casa + Home Assistant
|
||||
|
||||
| Tool | Scope | Sorgente |
|
||||
|------|-------|----------|
|
||||
| `get_home_dashboard` | home:read | Loogle Casa |
|
||||
| `get_home_weather` | home:read | Loogle Casa |
|
||||
| `get_network_overview` | home:read | Loogle Casa |
|
||||
| `get_network_failover_status` | home:read | Loogle Casa |
|
||||
| `get_ha_entity` | home:read | Home Assistant |
|
||||
| `list_ha_entities` | home:read | Home Assistant |
|
||||
| `search_ha_entities` | home:read | Home Assistant |
|
||||
|
||||
### Variabili `.env`
|
||||
|
||||
```bash
|
||||
LOOGLE_CASA_URL=https://casa.loogle.it
|
||||
LOOGLE_CASA_API_URL=http://192.168.128.81:5602 # opzionale, LAN
|
||||
LOOGLE_CASA_PASSWORD_DANIELE=... # daniele MCP → admin Casa
|
||||
|
||||
HA_URL=https://ha.loogle.it
|
||||
HA_API_URL=http://192.168.128.81:8123
|
||||
HA_TOKEN=<long-lived token HA>
|
||||
```
|
||||
|
||||
**Mapping utenti MCP → app:**
|
||||
- `daniele` → Casa `admin`, Irrigazione `admin`, Turni `daniely`
|
||||
|
||||
---
|
||||
|
||||
## P6 — Irrigazione + Turni
|
||||
|
||||
| Tool | Scope | Sorgente |
|
||||
|------|-------|----------|
|
||||
| `get_irrigation_status` | irrigation:read | irri.loogle.it |
|
||||
| `get_irrigation_zones` | irrigation:read | irri.loogle.it |
|
||||
| `get_irrigation_history` | irrigation:read | irri.loogle.it |
|
||||
| `get_turni_status` | — (pubblico) | turni.loogle.it |
|
||||
| `get_my_shifts` | turni:read | turni.loogle.it |
|
||||
| `list_turni_doctors` | turni:read | turni.loogle.it |
|
||||
|
||||
```bash
|
||||
IRRIGAZIONE_URL=https://irri.loogle.it
|
||||
IRRIGAZIONE_API_URL=http://192.168.128.81:5601
|
||||
IRRIGAZIONE_PASSWORD_DANIELE=...
|
||||
|
||||
TURNI_URL=https://turni.loogle.it
|
||||
TURNI_PASSWORD_DANIELE=... # utente Turni: daniely
|
||||
# oppure TURNI_JWT_DANIELE=...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P7 — RAG storico app
|
||||
|
||||
Il worker `loogle-mcp-indexer` indicizza periodicamente:
|
||||
- snapshot stato/zone Irrigazione
|
||||
- storico irrigazioni ed eventi
|
||||
- lavori manutenzione giardino
|
||||
- turni e anagrafica medici Turni
|
||||
|
||||
| Collection Qdrant | Contenuto |
|
||||
|-------------------|-----------|
|
||||
| `apps_shared_family` | Irrigazione + Turni (famiglia) |
|
||||
|
||||
| Tool | Scope | Funzione |
|
||||
|------|-------|----------|
|
||||
| `search_apps_knowledge` | knowledge:read | RAG solo app |
|
||||
| `search_knowledge` | knowledge:read | Include anche app |
|
||||
| `list_apps_indexed` | knowledge:read | Stato indicizzazione |
|
||||
| `reindex_apps` | admin | Forza sync |
|
||||
|
||||
```bash
|
||||
APPS_INDEX_ENABLED=yes
|
||||
APPS_INDEX_USER=daniele
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
docker exec loogle-mcp python3 /srv/scripts/test_p5_home.py
|
||||
docker exec loogle-mcp python3 /srv/scripts/test_p6_apps.py
|
||||
docker exec loogle-mcp python3 /srv/scripts/test_p7_apps_rag.py
|
||||
```
|
||||
@@ -1,322 +0,0 @@
|
||||
# Loogle MCP Hub — Architettura e parametri
|
||||
|
||||
Documento di riferimento per la piattaforma MCP domestica LOOGLE.IT.
|
||||
Ultimo aggiornamento: 2026-08-22
|
||||
|
||||
---
|
||||
|
||||
## Scopo
|
||||
|
||||
**Loogle MCP Hub** espone agli assistenti AI (Claude, ChatGPT, Gemini, Cursor) due capacità principali:
|
||||
|
||||
1. **Archivio contesto** — memoria persistente per progetti e task, isolata per utente
|
||||
2. **Knowledge base RAG** — ricerca semantica su documenti Paperless e sui contesti salvati
|
||||
|
||||
Tutti i dati restano in LAN/NAS; l’accesso esterno avviene solo via HTTPS + OAuth.
|
||||
|
||||
---
|
||||
|
||||
## Architettura logica
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Client AI (ChatGPT / Claude / Gemini / Cursor) │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ HTTPS + OAuth Bearer
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ NPM VIP 192.168.128.85 → mcp.loogle.it:443 │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Pi-1 :8700 loogle-mcp (FastAPI) │
|
||||
│ ├── /mcp JSON-RPC MCP │
|
||||
│ ├── /oauth/* OAuth 2.1 PKCE │
|
||||
│ └── /dashboard UI famiglia │
|
||||
│ Pi-1 :8700 loogle-mcp-indexer (worker Paperless→vector) │
|
||||
│ Pi-1 loogle-mcp-ollama (embedding query + indexer) │
|
||||
└───────┬─────────────────────────────┬───────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────┐ ┌─────────────────────┐
|
||||
│ SQLite │ │ Vector store │
|
||||
│ /data/*.db │ │ Qdrant DS920 :6333 │
|
||||
│ utenti OAuth │ │ o fallback SQLite │
|
||||
└───────────────┘ └─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ NFS /mnt/ha-apps/mcp/context/{utente}/projects/... │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Paperless docs.loogle.it (sorgente documenti + OCR) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Componenti e percorsi
|
||||
|
||||
| Componente | Host | Porta | Percorso / container |
|
||||
|------------|------|-------|----------------------|
|
||||
| MCP Gateway | Pi-1 | 8700 | `loogle-mcp` |
|
||||
| Indexer | Pi-1 | — | `loogle-mcp-indexer` |
|
||||
| Embedding | Pi-1 | 11434 (solo rete compose) | `loogle-mcp-ollama`, volume `loogle-mcp_ollama-models` |
|
||||
| Ollama di riserva | DS920 | 11434 | solo query se l'Ollama di Pi-1 non risponde; embedding per extrema |
|
||||
| Qdrant (primario) | DS920 | 6333 | opzionale, compose extrema |
|
||||
| Vector fallback | Pi-1 | — | `/data/vector_fallback.db` |
|
||||
| Context NFS | NAS | — | `/mnt/ha-apps/mcp/context/` |
|
||||
| DB SQLite | Pi-1 | — | `/home/daniely/docker/loogle-mcp/data/loogle_mcp.db` |
|
||||
| Compose locale | Pi-1 | — | `/home/daniely/docker/loogle-mcp/` |
|
||||
| Compose failover | Pi-1/Pi-2 | — | `/home/daniely/rete/compose/failover/loogle-mcp-compose.yml` |
|
||||
| Runbook | — | — | `/home/daniely/rete/ha/RUNBOOK-mcp.md` |
|
||||
|
||||
---
|
||||
|
||||
## URL e endpoint
|
||||
|
||||
| Servizio | URL |
|
||||
|----------|-----|
|
||||
| MCP (client AI) | `https://mcp.loogle.it/mcp` |
|
||||
| Dashboard web | `https://mcp.loogle.it/dashboard` |
|
||||
| Health | `https://mcp.loogle.it/health` |
|
||||
| OAuth authorize | `https://mcp.loogle.it/oauth/authorize` |
|
||||
| OAuth token | `https://mcp.loogle.it/oauth/token` |
|
||||
| Metadata OAuth | `https://mcp.loogle.it/.well-known/oauth-authorization-server` |
|
||||
| Metadata risorsa MCP | `https://mcp.loogle.it/.well-known/oauth-protected-resource` |
|
||||
|
||||
---
|
||||
|
||||
## DNS
|
||||
|
||||
| Record | Tipo | Valore | Dove configurato |
|
||||
|--------|------|--------|------------------|
|
||||
| `mcp.loogle.it` | A | `192.168.128.85` (VIP NPM) | Pi-hole `/etc/pihole/pihole.toml` → hosts |
|
||||
| Risoluzione LAN | — | Pi-hole → Unbound → split-horizon | Come `docs.loogle.it`, `pass.loogle.it` |
|
||||
|
||||
**Script setup:** `/home/daniely/rete/scripts/setup-mcp-dns-npm.sh`
|
||||
|
||||
**Verifica:**
|
||||
```bash
|
||||
dig +short mcp.loogle.it @127.0.0.1 # → 192.168.128.85
|
||||
curl -sf https://mcp.loogle.it/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NPM (reverse proxy)
|
||||
|
||||
| Parametro | Valore |
|
||||
|-----------|--------|
|
||||
| Proxy host ID | 30 |
|
||||
| Dominio | `mcp.loogle.it` |
|
||||
| Upstream normale | `192.168.128.80:8700` |
|
||||
| Certificato TLS | `npm-65` (Let's Encrypt) |
|
||||
| Failover route | `rete/ha/npm-routes.conf` riga `30\|mcp.loogle.it\|8700\|...` |
|
||||
| Websocket | ON |
|
||||
| Force SSL | ON |
|
||||
|
||||
**Failover:** `npm-failover-route.sh` aggiorna upstream su Pi-2 / DS920 in base allo scenario HA.
|
||||
|
||||
---
|
||||
|
||||
## Variabili d'ambiente (`.env`)
|
||||
|
||||
File: `/home/daniely/docker/loogle-mcp/.env`
|
||||
|
||||
| Variabile | Descrizione | Esempio |
|
||||
|-----------|-------------|---------|
|
||||
| `MCP_BASE_URL` | URL pubblico (issuer JWT) | `https://mcp.loogle.it` |
|
||||
| `MCP_JWT_SECRET` | Segreto firma token OAuth | *(stringa lunga casuale)* |
|
||||
| `MCP_OAUTH_CLIENT_ID` | Client OAuth pre-registrato | `loogle-mcp-public` |
|
||||
| `PAPERLESS_URL` | API documenti | `https://docs.loogle.it` |
|
||||
| `PAPERLESS_API_TOKEN` | Token API admin (fallback) | vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md) |
|
||||
| `PAPERLESS_API_TOKEN_{USER}` | Token API per utente | `DANIELE`, `LUCIA`, `DAVIDE`, `LUCA` |
|
||||
| `QDRANT_URL` | Vector DB remoto | `http://192.168.128.100:6333` |
|
||||
| `OLLAMA_URL` | Ollama DS920: riserva per le query, embedding in extrema | `http://192.168.128.100:11434` |
|
||||
| `OLLAMA_EMBED_MODEL` | Modello embedding | `nomic-embed-text` |
|
||||
| `OPENAI_API_KEY` | Fallback embedding cloud | *(opzionale)* |
|
||||
| `INDEXER_INTERVAL_MINUTES` | Intervallo sync Paperless | `30` |
|
||||
| `MCP_TOOL_TIMEOUT_S` | Tempo massimo di una chiamata MCP | `45` |
|
||||
| `MCP_THREAD_LIMIT` | Thread per i tool (eseguiti in parallelo) | `40` |
|
||||
| `QDRANT_RETRY_S` | Riprova Qdrant ogni N s mentre usa il fallback SQLite | `60` |
|
||||
| `OLLAMA_QUERY_TIMEOUT_S` | Timeout embedding query sull'Ollama DS920 | `20` |
|
||||
|
||||
Impostate in `docker-compose.yml` (sovrascrivono `.env`) per usare l'Ollama di Pi-1:
|
||||
|
||||
| Variabile | Valore | Note |
|
||||
|-----------|--------|------|
|
||||
| `OLLAMA_QUERY_URL` / `OLLAMA_INDEX_URL` | `http://ollama:11434` | query (~0,1 s) e indicizzazione (~0,9 s/chunk) |
|
||||
| `OLLAMA_QUERY_NUM_THREAD` | `2` | uguale a `cpus: 2` del container; stesse options per query e indexer, altrimenti Ollama ricarica il modello |
|
||||
| `THERMAL_SOURCE` | `local` | thermal gate su temperatura e carico di Pi-1 (`ds920` = sonda `DS920_THERMAL_URL`) |
|
||||
| `THERMAL_TEMP_SOFT_C` / `_HARD_C` | `70` / `78` | soglie Pi 5 |
|
||||
| `OLLAMA_EMBED_DELAY_S` | `0.5` | pausa tra un chunk e l'altro |
|
||||
|
||||
---
|
||||
|
||||
## Utenti e isolamento
|
||||
|
||||
| Persona | Username | Ruolo | Password iniziale |
|
||||
|---------|----------|-------|-------------------|
|
||||
| Daniele | `daniele` | admin | `daniele` *(cambiarla)* |
|
||||
| Lucia | `lucia` | user | `lucia` |
|
||||
| Davide | `davide` | user | `davide` |
|
||||
| Luca | `luca` | user | `luca` |
|
||||
|
||||
- Ogni utente vede i propri progetti in `/mnt/ha-apps/mcp/context/{username}/` più i
|
||||
**contesti condivisi** con la famiglia in `/mnt/ha-apps/mcp/context/_shared/`
|
||||
- Contesti condivisi: tutti li leggono e li aggiornano; archiviazione e ritorno a personale
|
||||
solo per il proprietario (`owner`) o l'admin. Ogni salvataggio registra `updated_by` e una
|
||||
sessione `AAAAMMGG-HHMMSS-{utente}.md`; `revision` aumenta a ogni salvataggio
|
||||
(`save_context if_revision` evita di sovrascrivere modifiche concorrenti)
|
||||
- I token OAuth JWT contengono `sub` = username; ogni tool filtra per utente
|
||||
- L’admin può `reindex_document`, vedere audit completo, revocare refresh token
|
||||
|
||||
**Scope OAuth:** `context:read`, `context:write`, `knowledge:read`, `knowledge:write`, `gitea:read`, `gitea:write`, `home:read`, `irrigation:read`, `turni:read`, `admin` (solo daniele)
|
||||
|
||||
---
|
||||
|
||||
## Tool MCP esposti
|
||||
|
||||
| Tool | Scope minimo | Funzione |
|
||||
|------|--------------|----------|
|
||||
| `ping` | — | Test connettività |
|
||||
| `whoami` | — | Utente e scope attivi |
|
||||
| `list_projects` | context:read | Elenco progetti personali + condivisi (`shared`) |
|
||||
| `create_project` | context:write | Nuovo progetto (`shared=true`: condiviso con la famiglia) |
|
||||
| `share_project` | context:write | Condivide un proprio progetto / lo riporta personale |
|
||||
| `get_project_context` | context:read | Legge memoria progetto |
|
||||
| `save_context` | context:write | Salva/appende memoria |
|
||||
| `archive_project` | context:write | Archivia progetto |
|
||||
| `search_context` | context:read | Ricerca semantica contesti |
|
||||
| `search_knowledge` | knowledge:read | RAG Paperless + contesti + Gitea + app |
|
||||
| `search_gitea_knowledge` | knowledge:read | RAG solo Gitea |
|
||||
| `search_apps_knowledge` | knowledge:read | RAG Irrigazione + Turni |
|
||||
| `list_apps_indexed` | knowledge:read | Stato indicizzazione app |
|
||||
| `reindex_apps` | admin | Re-sync RAG app |
|
||||
| `get_document` | knowledge:read | Testo documento Paperless |
|
||||
| `list_recent_documents` | knowledge:read | Ultimi doc indicizzati |
|
||||
| `reindex_document` | admin | Re-indicizza documento |
|
||||
| `get_home_dashboard` | home:read | Dashboard Loogle Casa |
|
||||
| `get_home_weather` | home:read | Meteo casa |
|
||||
| `get_network_overview` | home:read | Panoramica rete LAN |
|
||||
| `get_network_failover_status` | home:read | Stato failover cluster |
|
||||
| `get_ha_entity` | home:read | Entità Home Assistant |
|
||||
| `list_ha_entities` | home:read | Elenco entità HA |
|
||||
| `search_ha_entities` | home:read | Cerca entità HA |
|
||||
| `get_irrigation_status` | irrigation:read | Stato irrigazione |
|
||||
| `get_irrigation_zones` | irrigation:read | Zone irrigazione |
|
||||
| `get_irrigation_history` | irrigation:read | Storico irrigazioni |
|
||||
| `get_turni_status` | — | Stato servizio Turni |
|
||||
| `get_my_shifts` | turni:read | Turni utente corrente |
|
||||
| `list_turni_doctors` | turni:read | Medici Turni-Live |
|
||||
|
||||
Vedi anche tool Gitea in `docs/GITEA-TOKEN.md` e app homelab in `docs/APPS-INTEGRATION.md`.
|
||||
|
||||
---
|
||||
|
||||
## Storage contesto (per progetto)
|
||||
|
||||
```
|
||||
/mnt/ha-apps/mcp/context/{username}/projects/{project-id}/ # personali
|
||||
/mnt/ha-apps/mcp/context/_shared/projects/{project-id}/ # condivisi con la famiglia
|
||||
meta.json # titolo, tag, date, stato, revision, gitea_repo (opz.)
|
||||
# condivisi: shared, shared_with=family, owner, updated_by
|
||||
context.md # memoria persistente
|
||||
sessions/ # snapshot conversazioni (condivisi: suffisso -{utente})
|
||||
artifacts/ # file generati dagli agenti
|
||||
```
|
||||
|
||||
Gli ID dei condivisi sono univoci anche rispetto ai progetti personali di tutti gli utenti.
|
||||
Nel vector store ogni progetto è indicizzato con il `context.md` **completo** (sostituito a
|
||||
ogni salvataggio): `ctx_{utente}` per i personali, `ctx_shared_family` per i condivisi.
|
||||
|
||||
---
|
||||
|
||||
## Vector store / RAG
|
||||
|
||||
| Collection Qdrant | Contenuto |
|
||||
|-------------------|-----------|
|
||||
| `kb_shared_family` | Documenti famiglia (tag non personali) |
|
||||
| `kb_personal_{user}` | Documenti personali |
|
||||
| `ctx_{user}` | Embedding dei contesti agente |
|
||||
| `gitea_shared_family` | File testo da repo Gitea non privati |
|
||||
| `gitea_personal_{user}` | File testo da repo Gitea privati dell'owner |
|
||||
| `apps_shared_family` | Export Irrigazione + Turni (P7) |
|
||||
|
||||
**Nota Pi 5:** Qdrant locale su ARM (page size 16K) non è supportato. Default: Qdrant su DS920 o fallback SQLite in `/data/vector_fallback.db`.
|
||||
|
||||
**Pipeline indexer:** ogni 30 min:
|
||||
1. Paperless → chunk → embedding (Ollama Pi-1) → upsert `kb_*`
|
||||
2. Gitea (repo configurati in `GITEA_INDEX_REPOS_*`) → file `.md`/`.py`/`.sh` → segreti mascherati
|
||||
(`auth_pass`, `password=`, `token:`, chiavi private) → chunk → upsert `gitea_*`. I file
|
||||
`.env*` (tranne `.env.example`), chiavi e certificati non vengono mai indicizzati.
|
||||
3. Irrigazione + Turni → snapshot/storico → upsert `apps_shared_family` (vedi `docs/APPS-INTEGRATION.md`)
|
||||
|
||||
---
|
||||
|
||||
## Sicurezza
|
||||
|
||||
- TLS terminato su NPM (Let's Encrypt)
|
||||
- OAuth 2.1 Authorization Code + PKCE
|
||||
- JWT access token 1h + refresh token 30 giorni (revocabili)
|
||||
- Audit log: `{timestamp, user, tool, resource_id}` in SQLite
|
||||
- CrowdSec attivo su NPM
|
||||
- Nessun accesso cross-user ai dati
|
||||
|
||||
---
|
||||
|
||||
## Operazioni comuni
|
||||
|
||||
```bash
|
||||
# Stato servizi
|
||||
cd /home/daniely/docker/loogle-mcp && docker compose ps
|
||||
|
||||
# Log
|
||||
docker logs -f loogle-mcp
|
||||
docker logs -f loogle-mcp-indexer
|
||||
|
||||
# Redeploy
|
||||
./scripts/deploy.sh
|
||||
|
||||
# DNS + NPM (se reinstall)
|
||||
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh
|
||||
|
||||
# Failover route
|
||||
/home/daniely/rete/scripts/npm-failover-route.sh normal
|
||||
|
||||
# Backup dati MCP
|
||||
/home/daniely/rete/infra-monitor/mcp-restic-backup.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Failover (tier-b)
|
||||
|
||||
| Scenario | Comportamento |
|
||||
|----------|---------------|
|
||||
| Pi-1 down, Pi-2 up | Stack MCP su Pi-2, NPM upstream → `.81:8700` |
|
||||
| Extrema DS920 | NPM3 + upstream DS920 |
|
||||
| Monitoraggio | Probe `mcp_ok` in `failover-status.sh` |
|
||||
|
||||
---
|
||||
|
||||
## Da completare (post-install)
|
||||
|
||||
1. **`PAPERLESS_API_TOKEN_*`** in `.env` — vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md)
|
||||
2. **Ollama su DS920** con `nomic-embed-text`: riserva per le query ed embedding in extrema
|
||||
3. **Qdrant su DS920** (opzionale): `rete/compose/extrema-ds920/loogle-mcp-compose.yml`
|
||||
4. **Cambio password** per tutti gli utenti dal dashboard
|
||||
5. **`MCP_JWT_SECRET`** — impostare valore robusto in produzione
|
||||
|
||||
---
|
||||
|
||||
## Riferimenti
|
||||
|
||||
- [Guida utenti famiglia](docs/GUIDA-UTENTI.md)
|
||||
- [Onboarding tecnico client AI](docs/ONBOARDING.md)
|
||||
- [Runbook operativo](/home/daniely/rete/ha/RUNBOOK-mcp.md)
|
||||
- [README progetto](README.md)
|
||||
@@ -1,209 +0,0 @@
|
||||
# Token Gitea per Loogle MCP
|
||||
|
||||
## Cosa usare: Personal Access Token (PAT)
|
||||
|
||||
Gitea espone l'API REST su `https://git.loogle.it/api/v1/` con header:
|
||||
|
||||
```
|
||||
Authorization: token <token>
|
||||
```
|
||||
|
||||
Ogni utente Gitea ha i propri repository e permessi. L'hub MCP usa un token **per utente famiglia**, come Paperless.
|
||||
|
||||
---
|
||||
|
||||
## Dove generarlo
|
||||
|
||||
Per ogni utente che deve usare i tool Gitea da Claude/Cursor (daniele, lucia, …):
|
||||
|
||||
1. Accedi a https://git.loogle.it con quell'account
|
||||
2. **Impostazioni** → **Applicazioni** → **Genera nuovo token**
|
||||
3. Nome suggerito: `loogle-mcp`
|
||||
4. Scope minimi:
|
||||
- `read:repository` — list_repos, get_file, search_code, list_issues, get_issue, RAG
|
||||
- `write:repository` — **P4:** create_or_update_file
|
||||
- `write:user` — **P4:** create_gitea_repo (`POST /user/repos`)
|
||||
- `write:issue` — create_issue
|
||||
- `read:user` — consigliato per `/user/repos`
|
||||
5. Copia il token (mostrato una sola volta) in `.env`
|
||||
|
||||
### Alternativa CLI (admin, sul nodo con Gitea attivo)
|
||||
|
||||
```bash
|
||||
docker exec -u git gitea gitea admin user generate-access-token \
|
||||
--username daniele \
|
||||
--token-name loogle-mcp \
|
||||
--scopes 'read:repository,write:repository,write:issue,write:user,read:user'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configurazione in Loogle MCP
|
||||
|
||||
Modifica `/home/daniely/docker/loogle-mcp/.env`:
|
||||
|
||||
```env
|
||||
GITEA_URL=https://git.loogle.it
|
||||
GITEA_API_TOKEN_DANIELE=
|
||||
GITEA_API_TOKEN_LUCIA=
|
||||
GITEA_API_TOKEN_DAVIDE=
|
||||
GITEA_API_TOKEN_LUCA=
|
||||
```
|
||||
|
||||
| Utente MCP | Variabile `.env` | Account Gitea |
|
||||
|------------|------------------|---------------|
|
||||
| Daniele | `GITEA_API_TOKEN_DANIELE` | daniele |
|
||||
| Lucia | `GITEA_API_TOKEN_LUCIA` | lucia |
|
||||
| Davide | `GITEA_API_TOKEN_DAVIDE` | davide |
|
||||
| Luca | `GITEA_API_TOKEN_LUCA` | luca |
|
||||
|
||||
### URL API interno (opzionale)
|
||||
|
||||
Se `https://git.loogle.it` non è raggiungibile dal container MCP (rete LAN, failover), imposta un URL diretto:
|
||||
|
||||
```env
|
||||
GITEA_API_URL=http://192.168.128.81:3002
|
||||
```
|
||||
|
||||
I link nei risultati restano su `GITEA_URL` pubblico.
|
||||
|
||||
### Fallback admin
|
||||
|
||||
```env
|
||||
GITEA_API_TOKEN=<token-admin>
|
||||
```
|
||||
|
||||
Tutti gli utenti MCP useranno lo stesso token (sconsigliato salvo test).
|
||||
|
||||
---
|
||||
|
||||
## Tool MCP Gitea
|
||||
|
||||
| Tool | Scope | Descrizione |
|
||||
|------|-------|-------------|
|
||||
| `list_repos` | gitea:read | Repository accessibili |
|
||||
| `get_file` | gitea:read | Legge file o elenca directory |
|
||||
| `search_code` | gitea:read | Cerca nel codice (API `/search/code` o fallback tree) |
|
||||
| `list_issues` | gitea:read | Issue aperte/chiuse |
|
||||
| `get_issue` | gitea:read | Dettaglio issue |
|
||||
| `create_issue` | gitea:write | Crea issue |
|
||||
| `create_gitea_repo` | gitea:write | Crea repo sotto l'utente MCP corrente |
|
||||
| `create_or_update_file` | gitea:write | Commit singolo di un file (create/update via Contents API) |
|
||||
|
||||
Dopo modifica `.env`:
|
||||
|
||||
```bash
|
||||
cd /home/daniely/docker/loogle-mcp && docker compose up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verifica
|
||||
|
||||
```bash
|
||||
/home/daniely/docker/loogle-mcp/scripts/test_gitea_integration.py
|
||||
```
|
||||
|
||||
Oppure da MCP (con token OAuth): tool `list_repos` e `get_file` su `daniele/rete`.
|
||||
|
||||
---
|
||||
|
||||
## Collegamento progetto ↔ repository (P2)
|
||||
|
||||
Ogni progetto MCP può referenziare un repo Gitea in `meta.json`:
|
||||
|
||||
```json
|
||||
"gitea_repo": "daniele/rete"
|
||||
```
|
||||
|
||||
### Tool
|
||||
|
||||
| Tool | Azione |
|
||||
|------|--------|
|
||||
| `create_project` | Parametri opzionali `gitea_repo`, `seed_from_gitea` |
|
||||
| `link_project_repo` | Collega, cambia repo o scollega (`gitea_repo` vuoto) |
|
||||
| `get_project_context` | Aggiunge blocco `gitea` con README, `docs/` e issue aperte |
|
||||
|
||||
### Seed README
|
||||
|
||||
Con `seed_from_gitea: true` il README del repo viene importato in `context.md` (una sola volta, marker `<!-- seed:gitea ... -->`).
|
||||
|
||||
### Verifica P2
|
||||
|
||||
```bash
|
||||
/home/daniely/docker/loogle-mcp/scripts/test_project_gitea_p2.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RAG su repository Gitea (P3)
|
||||
|
||||
Il worker `loogle-mcp-indexer` indicizza periodicamente file testo dai repo Gitea (`.md`, `.py`, `.sh`, …) in collection Qdrant dedicate:
|
||||
|
||||
| Collection | Contenuto |
|
||||
|------------|-----------|
|
||||
| `gitea_shared_family` | Repo non privati |
|
||||
| `gitea_personal_{user}` | Repo privati dell'owner |
|
||||
|
||||
### Config `.env`
|
||||
|
||||
```env
|
||||
GITEA_INDEX_ENABLED=yes
|
||||
GITEA_INDEX_REPOS_DANIELE=daniele/rete,daniele/loogle-scripts
|
||||
GITEA_INDEX_MAX_FILES_PER_REPO=150
|
||||
GITEA_INDEX_MAX_FILE_BYTES=120000
|
||||
```
|
||||
|
||||
Se `GITEA_INDEX_REPOS_{USER}` è vuoto → tutti i repo accessibili via API.
|
||||
|
||||
### Tool MCP
|
||||
|
||||
| Tool | Descrizione |
|
||||
|------|-------------|
|
||||
| `search_gitea_knowledge` | Ricerca semantica solo su Gitea |
|
||||
| `search_knowledge` | Include Paperless + contesti + Gitea |
|
||||
| `list_gitea_indexed_files` | Stato indicizzazione |
|
||||
| `reindex_gitea_repo` | Forza re-index di un repo |
|
||||
|
||||
### Verifica P3
|
||||
|
||||
```bash
|
||||
docker exec loogle-mcp python3 /srv/scripts/test_gitea_rag_p3.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scrittura su Gitea (P4)
|
||||
|
||||
L'AI può creare repository e committare singoli file via API Gitea (senza git push locale).
|
||||
|
||||
| Tool | Scope | Azione |
|
||||
|------|-------|--------|
|
||||
| `create_gitea_repo` | gitea:write | Crea repo sotto l'utente MCP autenticato |
|
||||
| `create_or_update_file` | gitea:write | Crea o aggiorna un file (Contents API + commit) |
|
||||
|
||||
Regole:
|
||||
|
||||
- Scrittura consentita solo su repo di cui l'utente è **owner** (salvo admin MCP).
|
||||
- Dopo scrittura, opzionalmente re-indicizza il file nel RAG (`reindex: true` default).
|
||||
- Token Gitea: `write:user` (create repo) + `write:repository` (file).
|
||||
|
||||
### Workspace davide e luca
|
||||
|
||||
| Utente | Repo Gitea | Progetto MCP |
|
||||
|--------|------------|--------------|
|
||||
| davide | `davide/progetti` | Progetti Davide |
|
||||
| luca | `luca/progetti` | Progetti Luca |
|
||||
|
||||
Setup iniziale:
|
||||
|
||||
```bash
|
||||
docker exec loogle-mcp python3 /srv/scripts/setup_gitea_p4_repos.py
|
||||
```
|
||||
|
||||
### Verifica P4
|
||||
|
||||
```bash
|
||||
docker exec loogle-mcp python3 /srv/scripts/test_gitea_p4.py
|
||||
```
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
# Guida amministratore — Daniele
|
||||
|
||||
Guida operativa per gestire e usare Loogle MCP Hub.
|
||||
|
||||
---
|
||||
|
||||
## Accesso rapido
|
||||
|
||||
| Cosa | URL / comando |
|
||||
|------|----------------|
|
||||
| Dashboard admin | https://mcp.loogle.it/dashboard (login `daniele`) |
|
||||
| Health | `curl -sf https://mcp.loogle.it/health` |
|
||||
| Log gateway | `docker logs -f loogle-mcp` |
|
||||
| Log indexer | `docker logs -f loogle-mcp-indexer` |
|
||||
| Compose | `cd /home/daniely/docker/loogle-mcp` |
|
||||
|
||||
---
|
||||
|
||||
## Configurazione iniziale (checklist)
|
||||
|
||||
- [x] Stack Docker avviato (`docker compose up -d`)
|
||||
- [x] DNS Pi-hole: `mcp.loogle.it` → `192.168.128.85`
|
||||
- [x] NPM proxy host 30 + TLS Let's Encrypt
|
||||
- [ ] Token Paperless in `.env` — vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md) (Profilo utente, non «Applicazione social»)
|
||||
- [ ] `MCP_JWT_SECRET` robusto in `.env`
|
||||
- [ ] Ollama su DS920 con `ollama pull nomic-embed-text`
|
||||
- [ ] Qdrant su DS920 (opzionale, vedi compose extrema)
|
||||
- [ ] Password cambiate per tutta la famiglia
|
||||
- [ ] Ogni familiare ha collegato almeno un client AI
|
||||
|
||||
**Script utili:**
|
||||
```bash
|
||||
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh # DNS + NPM
|
||||
./scripts/deploy.sh # rebuild + restart
|
||||
/home/daniely/rete/scripts/npm-failover-route.sh normal # upstream failover
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Come usare tu (Daniele)
|
||||
|
||||
### Dashboard
|
||||
|
||||
1. https://mcp.loogle.it/dashboard — login `daniele`
|
||||
2. Vedi progetti, audit log completo, revoca refresh token (sezione admin)
|
||||
|
||||
### Con Cursor / Claude / ChatGPT
|
||||
|
||||
Stesso URL MCP: `https://mcp.loogle.it/mcp`
|
||||
|
||||
**Cursor** — aggiungi in `.cursor/mcp.json` del progetto:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"loogle-mcp": {
|
||||
"url": "https://mcp.loogle.it/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Workflow consigliato per un task
|
||||
|
||||
1. **create_project** — es. «Homelab MCP», tag `rete`, `2026`
|
||||
2. Lavori con l’AI (Cursor, Claude, ecc.)
|
||||
3. **save_context** — a fine sessione salvi decisioni e stato
|
||||
4. **search_knowledge** — recuperi doc da Paperless quando serve
|
||||
5. **search_context** — ritrovi discussioni passate semanticamente
|
||||
|
||||
### Digest giornaliero agenti → `homelab-loogle`
|
||||
|
||||
Ogni sera (cron `22:45` su Pi-1) lo script `scripts/export_daily_agent_digest.py` aggiorna un blocco `<!-- daily-digest:YYYY-MM-DD -->` sul progetto **homelab-loogle**, con:
|
||||
|
||||
- query e conclusioni dagli agent transcript Cursor
|
||||
- tool MCP usati (audit_log)
|
||||
- commit git del giorno (`rete`, `loogle-mcp`)
|
||||
- sync `~/.cursor/plans/*.plan.md` → `artifacts/plans/` + indice in `context.md` (solo i piani toccati quel giorno compaiono nel digest)
|
||||
|
||||
Claude (e Cursor) lo leggono con `get_project_context` su `homelab-loogle`.
|
||||
|
||||
```bash
|
||||
cd /home/daniely/docker/loogle-mcp
|
||||
python3 scripts/export_daily_agent_digest.py --dry-run
|
||||
python3 scripts/export_daily_agent_digest.py --force --no-index
|
||||
# opzionale: indicizza il blocco in Qdrant ctx_* (più lento, Ollama)
|
||||
python3 scripts/export_daily_agent_digest.py --force
|
||||
```
|
||||
|
||||
Log: `data/daily-digest.log`. Idempotente; `--force` sovrascrive il giorno.
|
||||
|
||||
### Tool solo admin
|
||||
|
||||
- `reindex_document` — forza re-indicizzazione di un doc Paperless
|
||||
- Revoca token refresh da dashboard
|
||||
- Audit log di tutti gli utenti via `/api/audit`
|
||||
|
||||
---
|
||||
|
||||
## Gestione utenti
|
||||
|
||||
Gli utenti sono in SQLite (`loogle_mcp.db`). Password hash PBKDF2.
|
||||
|
||||
| Utente | Admin | Reset password |
|
||||
|--------|-------|----------------|
|
||||
| daniele | sì | dashboard o diretto DB |
|
||||
| lucia, davide, luca | no | dashboard (self-service) |
|
||||
|
||||
Password iniziale = username. `must_change_password` è attivo al primo login consigliato.
|
||||
|
||||
Per aggiungere utenti in futuro: estendi `FAMILY_USERS` in `app/auth.py` e redeploy.
|
||||
|
||||
---
|
||||
|
||||
## Paperless → knowledge base
|
||||
|
||||
Vedi guida completa: [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md)
|
||||
|
||||
**Non usare** i token «Applicazione social» dell'admin Django (schermata nell'immagine): servono al login OAuth web di Paperless, non all'API REST MCP.
|
||||
|
||||
**Usa** i token API utente: docs.loogle.it → menu utente → **Profilo** → pulsante freccia circolare accanto a «Token API».
|
||||
|
||||
Configura in `.env` (un token per ogni account Paperless della famiglia):
|
||||
|
||||
```bash
|
||||
PAPERLESS_API_TOKEN_DANIELE=<token account Daniele>
|
||||
PAPERLESS_API_TOKEN_LUCIA=<token account Lucia>
|
||||
PAPERLESS_API_TOKEN_DAVIDE=<token account Davide>
|
||||
PAPERLESS_API_TOKEN_LUCA=<token account Luca>
|
||||
```
|
||||
|
||||
Ogni familiare genera il proprio token da docs.loogle.it → **Profilo** → Token API. Se un utente non ha ancora account Paperless, lascia la riga vuota finché non lo crei.
|
||||
|
||||
Alternativa: solo `PAPERLESS_API_TOKEN=` con token admin (Daniele superuser) — tutti gli utenti MCP condividono lo stesso scope.
|
||||
|
||||
Riavvia indexer:
|
||||
```bash
|
||||
cd /home/daniely/docker/loogle-mcp && docker compose restart loogle-mcp loogle-mcp-indexer
|
||||
```
|
||||
|
||||
**Visibilità documenti (tag Paperless):**
|
||||
- Tag `personal` / `privato` → solo collection personale del owner
|
||||
- Senza tag personali → `kb_shared_family` (tutti in famiglia)
|
||||
|
||||
---
|
||||
|
||||
## Embedding e Qdrant
|
||||
|
||||
| Componente | Dove | Note |
|
||||
|----------|------|------|
|
||||
| Ollama | DS920 `:11434` | `OLLAMA_EMBED_MODEL=nomic-embed-text` |
|
||||
| Qdrant | DS920 `:6333` | Non gira su Pi5 (limitazione ARM) |
|
||||
| Fallback | `/data/vector_fallback.db` | Automatico se Qdrant non raggiungibile |
|
||||
|
||||
Deploy Qdrant su DS920:
|
||||
```bash
|
||||
# sul DS920, path mirror extrema
|
||||
docker compose -f /volume1/extrema/compose/extrema-ds920/loogle-mcp-compose.yml up -d qdrant
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DNS e NPM — cosa è stato configurato
|
||||
|
||||
**Pi-hole** (`/etc/pihole/pihole.toml`):
|
||||
```
|
||||
"192.168.128.85 mcp.loogle.it"
|
||||
```
|
||||
|
||||
**NPM** proxy host ID 30:
|
||||
- Dominio: `mcp.loogle.it`
|
||||
- Backend: `192.168.128.80:8700`
|
||||
- Cert: `npm-65`
|
||||
|
||||
**Failover** (`npm-routes.conf`):
|
||||
```
|
||||
30|mcp.loogle.it|8700|pi1|pi1|local|ds920
|
||||
```
|
||||
|
||||
Se reinstalli NPM da zero, riesegui:
|
||||
```bash
|
||||
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh
|
||||
/home/daniely/sync-npm-full.sh # sync Pi2 + DS920
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoraggio e backup
|
||||
|
||||
- **Failover status:** probe `mcp_ok` / `mcp_be` in `failover-status.sh`
|
||||
- **Runbook incidenti:** `/home/daniely/rete/ha/RUNBOOK-mcp.md`
|
||||
- **Backup restic:** `/home/daniely/rete/infra-monitor/mcp-restic-backup.sh`
|
||||
|
||||
---
|
||||
|
||||
## Onboarding famiglia
|
||||
|
||||
Invia a ciascuno:
|
||||
|
||||
- [Guida utenti](GUIDA-UTENTI.md) — istruzioni semplici per ChatGPT/Claude/Gemini
|
||||
- URL dashboard per cambio password
|
||||
- Username personale (non condividere password)
|
||||
|
||||
Progetto demo già creato per ogni utente (es. `progetti-personali` per Lucia).
|
||||
|
||||
---
|
||||
|
||||
## Documentazione tecnica
|
||||
|
||||
- [Architettura e parametri](ARCHITETTURA.md)
|
||||
- [Onboarding client AI (dettaglio)](ONBOARDING.md)
|
||||
- [Runbook HA](/home/daniely/rete/ha/RUNBOOK-mcp.md)
|
||||
@@ -1,237 +0,0 @@
|
||||
# Paperless — Guida per la famiglia LOOGLE
|
||||
|
||||
**Archivio documenti di casa:** https://docs.loogle.it
|
||||
|
||||
Questa guida spiega **perché** usiamo Paperless, **come** configurarlo e **come** installare l’app sul telefono.
|
||||
|
||||
---
|
||||
|
||||
## Perché usiamo Paperless
|
||||
|
||||
Paperless è l’**archivio digitale di casa**: bollette, contratti, manuali, referti, ricevute, documenti scolastici, ecc.
|
||||
|
||||
| Prima (cartaceo) | Con Paperless |
|
||||
|------------------|---------------|
|
||||
| Documenti sparsi in cassetti e cartelle | Tutto in un unico archivio searchable |
|
||||
| Difficile trovare «la bolletta luce del 2024» | Cerca per parola, tag, mittente, data |
|
||||
| Fotocopie e PDF persi nel telefono | PDF centralizzati, backup su NAS di casa |
|
||||
| Solo chi ha il foglio fisico | Chi ha l’account può consultare (con permessi) |
|
||||
|
||||
**Vantaggi per noi:**
|
||||
|
||||
- **Resta in casa** — i file sono sui nostri dispositivi (Pi + NAS), non su Google Drive o iCloud di terzi
|
||||
- **OCR in italiano** — Paperless legge il testo nei PDF e nelle scansioni, così puoi cercare anche dentro il documento
|
||||
- **Integrazione** — l’assistente AI (MCP) può cercare documenti già archiviati quando chiedi «trova la bolletta gas»
|
||||
- **Accesso da telefono** — consulta e carica documenti ovunque (in rete casa o via VPN)
|
||||
|
||||
---
|
||||
|
||||
## Cos’è, in pratica
|
||||
|
||||
Paperless-ngx è un sito web dove:
|
||||
|
||||
1. **Carichi** un PDF o una foto (upload, email, cartella sul telefono)
|
||||
2. **Paperless** fa OCR, propone titolo, data, tag
|
||||
3. **Tu** confermi o correggi (opzionale)
|
||||
4. **Archivi** — il documento resta indicizzato e ricercabile
|
||||
|
||||
**Indirizzo:** https://docs.loogle.it
|
||||
Funziona da browser (PC, tablet, telefono) o da app dedicata.
|
||||
|
||||
---
|
||||
|
||||
## Account famiglia
|
||||
|
||||
| Nome | Username | Password iniziale |
|
||||
|------|----------|-------------------|
|
||||
| Daniele | `daniele` | *(chiedi a Daniele)* |
|
||||
| Lucia | `lucia` | `lucia` |
|
||||
| Davide | `davide` | `davide` |
|
||||
| Luca | `luca` | `luca` |
|
||||
|
||||
**Al primo accesso cambia la password** (vedi sotto).
|
||||
|
||||
Ogni persona vede i **propri** documenti e quelli **condivisi in famiglia** (secondo i permessi impostati).
|
||||
|
||||
---
|
||||
|
||||
## Primo accesso e configurazione (browser)
|
||||
|
||||
### 1. Accedi al sito
|
||||
|
||||
1. Apri **https://docs.loogle.it** (da casa o in VPN — vedi sotto)
|
||||
2. Inserisci **username** e **password**
|
||||
3. Se è la prima volta, Paperless può chiederti di completare il profilo
|
||||
|
||||
### 2. Cambia la password
|
||||
|
||||
1. Clicca sul **nome utente** in alto a destra
|
||||
2. Vai su **Profilo** / **Impostazioni**
|
||||
3. Sezione **Password** → inserisci la vecchia e la nuova
|
||||
4. Salva
|
||||
|
||||
### 3. Conosci l’interfaccia
|
||||
|
||||
| Area | A cosa serve |
|
||||
|------|----------------|
|
||||
| **Dashboard** | Panoramica, documenti recenti |
|
||||
| **Documenti** | Elenco e ricerca di tutti i PDF |
|
||||
| **Inbox** (se attiva) | Documenti appena caricati da revisionare |
|
||||
| **Tag / Tipi / Corrispondenti** | Organizzazione (es. tag `bollette`, `salute`) |
|
||||
|
||||
### 4. Caricare un documento
|
||||
|
||||
**Da computer:**
|
||||
|
||||
1. **Documenti** → **Carica** (o trascina il PDF nella finestra)
|
||||
2. Attendi l’OCR (qualche secondo)
|
||||
3. Controlla titolo, data, tag → **Salva**
|
||||
|
||||
**Da telefono (browser):**
|
||||
|
||||
- Stesso procedimento dal browser mobile su docs.loogle.it
|
||||
|
||||
**Suggerimento:** usa tag semplici e coerenti, es. `bollette`, `casa`, `auto`, `salute`, `scuola`.
|
||||
|
||||
### 5. Cercare un documento
|
||||
|
||||
- Barra **Cerca** in alto: parole chiave (es. «enel», «bolletta», «assicurazione»)
|
||||
- Filtri per **tag**, **tipo documento**, **data**
|
||||
- Paperless cerca anche **dentro** il testo del PDF (OCR)
|
||||
|
||||
### 6. Scaricare o condividere
|
||||
|
||||
- Apri un documento → icona **Download** o **Condividi**
|
||||
- Puoi inviare il PDF via email/WhatsApp dal telefono
|
||||
|
||||
---
|
||||
|
||||
## Accesso da fuori casa (VPN)
|
||||
|
||||
`docs.loogle.it` è raggiungibile **in rete di casa** (Wi‑Fi domestico o dati se sei a casa).
|
||||
|
||||
**Se sei fuori** (ufficio, viaggio): connettiti prima alla **VPN di casa**:
|
||||
|
||||
- Indirizzo: **https://vpn.loogle.it**
|
||||
- Credenziali WireGuard: chiedi a Daniele (file `.conf` o QR code)
|
||||
|
||||
Dopo la VPN, apri normalmente https://docs.loogle.it o l’app Paperless.
|
||||
|
||||
---
|
||||
|
||||
## App sul telefono
|
||||
|
||||
Paperless **non ha un’app ufficiale** del team Paperless-ngx, ma esistono app **compatibili** che si collegano al nostro server.
|
||||
|
||||
### Consigliate
|
||||
|
||||
| App | Android | iPhone/iPad |
|
||||
|-----|---------|-------------|
|
||||
| **Paperless Mobile** | [Google Play](https://play.google.com/store/apps/details?id=de.astubenbord.paperless_mobile) | Cerca «Paperless Mobile» su App Store |
|
||||
| **Paperless Go** | [Google Play](https://play.google.com/store/apps/details?id=com.github.iweinzierl.paperlessgo) / F-Droid | [App Store](https://apps.apple.com/app/paperless-go/id6738696198) |
|
||||
|
||||
Per la famiglia consigliamo **Paperless Mobile** (molto usata, italiano supportato) oppure **Paperless Go** (interfaccia moderna, scansione documenti).
|
||||
|
||||
---
|
||||
|
||||
## Installazione app — passo passo
|
||||
|
||||
### Android (Paperless Mobile)
|
||||
|
||||
1. Apri **Google Play Store**
|
||||
2. Cerca **«Paperless Mobile»** (sviluppatore: Andrei Stübenbord)
|
||||
3. **Installa**
|
||||
4. Apri l’app → **Aggiungi account** / **Connect**
|
||||
5. **URL server:** `https://docs.loogle.it`
|
||||
6. **Username** e **password** (le tue credenziali Paperless)
|
||||
7. Accetta eventuale avviso certificato (connessione sicura LOOGLE)
|
||||
8. Fatto — vedi l’elenco documenti
|
||||
|
||||
### iPhone / iPad (Paperless Mobile o Paperless Go)
|
||||
|
||||
1. Apri **App Store**
|
||||
2. Cerca **«Paperless Mobile»** o **«Paperless Go»**
|
||||
3. **Scarica** e apri
|
||||
4. Inserisci:
|
||||
- **Server URL:** `https://docs.loogle.it`
|
||||
- **Username** / **Password**
|
||||
5. Opzionale: attiva **Face ID / Touch ID** per proteggere l’app
|
||||
6. Fatto
|
||||
|
||||
### Scansione con il telefono (Paperless Go)
|
||||
|
||||
1. Nell’app → **Scansiona** / icona fotocamera
|
||||
2. Inquadra il documento (bolletta, lettera)
|
||||
3. Ritaglia e conferma
|
||||
4. Scegli tag / tipo se richiesto → **Carica**
|
||||
5. Il PDF compare in Paperless dopo pochi secondi
|
||||
|
||||
---
|
||||
|
||||
## Configurazione consigliata nell’app
|
||||
|
||||
| Impostazione | Consiglio |
|
||||
|--------------|-----------|
|
||||
| **Tema scuro** | Più comodo di sera |
|
||||
| **Biometria** | Face ID / impronta per aprire l’app |
|
||||
| **Notifiche** | Opzionali (nuovi documenti in inbox) |
|
||||
| **Account multipli** | Un account per persona (non condividere la stessa login) |
|
||||
|
||||
---
|
||||
|
||||
## Buone abitudini
|
||||
|
||||
1. **Carica subito** bollette e documenti importanti (non lasciare pile di carta)
|
||||
2. **Usa tag** — pochi ma chiari (`luce`, `gas`, `banca`, `medico`)
|
||||
3. **Controlla l’inbox** — correggi titolo/data se Paperless sbaglia
|
||||
4. **Non eliminare** l’originale cartaceo finché non sei sicuro che il PDF sia ok
|
||||
5. **Cambia password** se pensi che qualcuno l’abbia vista
|
||||
|
||||
---
|
||||
|
||||
## Collegamento con l’assistente AI (MCP)
|
||||
|
||||
Se usi ChatGPT, Claude o Cursor collegati a **Loogle MCP** (`https://mcp.loogle.it`):
|
||||
|
||||
- Puoi chiedere: *«Cerca in Paperless la bolletta luce dell’ultimo trimestre»*
|
||||
- L’AI cerca solo nei documenti **che il tuo account può vedere**
|
||||
- Per funzionare, i documenti devono **già essere** in Paperless
|
||||
|
||||
La guida MCP è separata: `GUIDA-UTENTI.pdf` nella stessa cartella.
|
||||
|
||||
---
|
||||
|
||||
## Problemi comuni
|
||||
|
||||
| Problema | Cosa fare |
|
||||
|----------|-----------|
|
||||
| «Sito non raggiungibile» | Sei in rete casa? Se no, attiva **VPN** (vpn.loogle.it) |
|
||||
| Login fallisce | Controlla username/password; prova da browser prima |
|
||||
| App «connessione fallita» | URL esatto: `https://docs.loogle.it` (con **https**) |
|
||||
| Certificato / SSL | Accetta il certificato LOOGLE; data/ora telefono corretta |
|
||||
| Non vedo un documento | Potrebbe essere personale di un altro utente; chiedi a Daniele |
|
||||
| OCR illeggibile | Scansiona con buona luce; PDF meglio di foto storta |
|
||||
|
||||
---
|
||||
|
||||
## Contatti
|
||||
|
||||
**Daniele** — account, permessi, VPN, problemi tecnici.
|
||||
|
||||
---
|
||||
|
||||
## Scheda rapida
|
||||
|
||||
```
|
||||
Sito: https://docs.loogle.it
|
||||
VPN fuori: https://vpn.loogle.it
|
||||
App Android: Paperless Mobile (Play Store)
|
||||
App iPhone: Paperless Mobile o Paperless Go (App Store)
|
||||
Server URL: https://docs.loogle.it
|
||||
Login: il tuo username (lucia / davide / luca)
|
||||
Primo passo: cambia password → installa app → carica una bolletta di prova
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Loogle — documenti di famiglia, a casa nostra.*
|
||||
@@ -1,347 +0,0 @@
|
||||
# Guida utenti — Loogle MCP Hub
|
||||
|
||||
Questa guida è per **Lucia, Davide e Luca** (e chiunque usi gli assistenti AI collegati a casa).
|
||||
|
||||
---
|
||||
|
||||
## Cos’è
|
||||
|
||||
Loogle MCP è la **memoria di casa** per i tuoi assistenti AI. Permette a ChatGPT, Claude o Gemini di:
|
||||
|
||||
- **Ricordare** i tuoi progetti (es. ristrutturazione, studio, hobby)
|
||||
- **Cercare** documenti che abbiamo già archiviato in Paperless (bollette, manuali, PDF)
|
||||
- **Salvare** note e riassunti che l’AI produce durante un lavoro, così non devi ripetere tutto ogni volta
|
||||
|
||||
I tuoi dati restano **solo sui nostri dispositivi** a casa, non nel cloud dell’AI.
|
||||
|
||||
---
|
||||
|
||||
## Le tue credenziali
|
||||
|
||||
| Nome | Username | Password iniziale |
|
||||
|------|----------|-------------------|
|
||||
| Lucia | `lucia` | `lucia` |
|
||||
| Davide | `davide` | `davide` |
|
||||
| Luca | `luca` | `luca` |
|
||||
|
||||
**Al primo accesso cambia la password:**
|
||||
|
||||
1. Apri https://mcp.loogle.it/dashboard
|
||||
2. Accedi con username e password
|
||||
3. Sezione **Cambia password** → inserisci la nuova password (minimo 6 caratteri)
|
||||
|
||||
Ogni persona vede **solo i propri progetti**. Non puoi vedere (né l’AI) i progetti degli altri familiari.
|
||||
|
||||
---
|
||||
|
||||
## Collegare ChatGPT
|
||||
|
||||
Richiede account **Plus, Pro, Business o Edu**.
|
||||
|
||||
1. Apri ChatGPT → **Impostazioni**
|
||||
2. Attiva **Developer Mode** (se non già attivo)
|
||||
3. Vai a **Connectors** → **Add MCP Connector**
|
||||
4. Inserisci l’URL del server:
|
||||
```
|
||||
https://mcp.loogle.it/mcp
|
||||
```
|
||||
5. Clicca **Connect** — si apre una pagina di login Loogle
|
||||
6. Accedi con **il tuo** username e password (es. `lucia` / la tua nuova password)
|
||||
7. Autorizza l’accesso
|
||||
|
||||
Nelle conversazioni, abilita i tool del connector **Loogle MCP** quando vuoi usare memoria o documenti.
|
||||
|
||||
### Esempi di richieste a ChatGPT
|
||||
|
||||
- *«Usa list_projects per vedere i miei progetti»*
|
||||
- *«Leggi il contesto del progetto X e riassumilo»*
|
||||
- *«Cerca nei documenti di casa informazioni sulla caldaia»*
|
||||
- *«Salva in save_context questo riepilogo del lavoro fatto oggi»*
|
||||
|
||||
---
|
||||
|
||||
## Collegare Claude (App / Desktop)
|
||||
|
||||
1. Apri Claude → **Settings** → **Connectors**
|
||||
2. **Add remote MCP server**
|
||||
3. URL:
|
||||
```
|
||||
https://mcp.loogle.it/mcp
|
||||
```
|
||||
4. Al primo utilizzo: login con le **tue** credenziali Loogle MCP
|
||||
5. Autorizza
|
||||
|
||||
Claude potrà usare gli stessi tool (progetti, ricerca documenti, salvataggio contesto).
|
||||
|
||||
### Far usare MCP ogni giorno (Desktop + mobile)
|
||||
|
||||
Claude **non** salva il contesto da solo: va istruito una volta e poi richiamato con abitudini brevi.
|
||||
|
||||
**1. Istruzioni personalizzate (Desktop e App — stesso account)**
|
||||
Settings → **Profile** / **Custom instructions** (o *What should Claude know about you?*) e incolla:
|
||||
|
||||
```
|
||||
Hai il connector Loogle MCP (mcp.loogle.it). Per lavori su casa, documenti, progetti o codice famiglia:
|
||||
- All’inizio: list_projects → get_project_context sul progetto rilevante.
|
||||
- Per documenti/bollette/manuali: search_knowledge (non inventare).
|
||||
- Per codice/runbook Gitea: search_gitea_knowledge o get_file.
|
||||
- A fine chat utile: save_context (append) con decisioni e next step.
|
||||
Se non esiste un progetto adatto, create_project prima di salvare.
|
||||
```
|
||||
|
||||
**2. Abilita sempre i tool del connector**
|
||||
In ogni chat nuova, assicurati che **Loogle MCP** sia attivo nei tool/connectors (su Desktop a volte va riacceso per conversazione).
|
||||
|
||||
**3. Frasi-ancora (funzionano anche da mobile)**
|
||||
Usa all’inizio o alla fine, senza ricordare i nomi tool:
|
||||
|
||||
- *«Controlla prima su Loogle MCP il mio progetto e i documenti rilevanti.»*
|
||||
- *«A fine risposta salva su MCP un riepilogo nel progetto giusto.»*
|
||||
- *«Cerca in Paperless via MCP la bolletta / il manuale …»*
|
||||
- *«Come sta l’irrigazione / i turni oggi? Usa MCP.»*
|
||||
|
||||
**4. Un progetto = un tema**
|
||||
Es. «Rinnovo bagno», «Homelab», «Studio». Più il `context.md` è pieno di decisioni reali, più Claude lo riuserà da solo.
|
||||
|
||||
**5. Mobile**
|
||||
Stesso account = stesse custom instructions. MCP remoto dipende dal supporto app Claude; se i tool non compaiono, usa Desktop/web per i salvataggi e da mobile chiedi almeno *«ricorda di aggiornare MCP quando torno al computer»* oppure ripeti la frase-ancora quando i connector sono disponibili.
|
||||
|
||||
**6. Verifica**
|
||||
Dashboard https://mcp.loogle.it/dashboard → ultime azioni: devono comparire `get_project_context` / `save_context` / `search_knowledge` dopo le chat.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Collegare Cursor IDE
|
||||
|
||||
1. Crea o modifica `~/.cursor/mcp.json` (globale) oppure `.cursor/mcp.json` nel progetto:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"loogle-mcp": {
|
||||
"url": "https://mcp.loogle.it/mcp",
|
||||
"auth": {
|
||||
"CLIENT_ID": "cursor",
|
||||
"scopes": [
|
||||
"context:read", "context:write",
|
||||
"knowledge:read", "knowledge:write",
|
||||
"gitea:read", "gitea:write",
|
||||
"home:read", "irrigation:read", "turni:read"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Riavvia Cursor completamente** (esci dall’app, non solo chiudi la finestra)
|
||||
3. **Cursor Settings** → **Tools & MCP** → **Connect** su `loogle-mcp` (**una sola volta**, attendi 10–15 s)
|
||||
4. Si apre il browser su **Loogle MCP — Login** → accedi con le **tue** credenziali MCP
|
||||
5. In chat, chiedi all'AI di usare i tool (`list_projects`, `search_knowledge`, …)
|
||||
|
||||
### Cursor: «Unauthorized» nei log
|
||||
|
||||
| Log Cursor | Significato |
|
||||
|------------|-------------|
|
||||
| `MCP OAuth redirect` + `Unauthorized` | **Normale prima del login** — clicca **Connect** e completa il browser |
|
||||
| `Redirect URI non consentito` | Errore server (già risolto) — riprova **Reconnect** |
|
||||
| `credentials cleared` | Hai cliccato Connect/Disconnect in loop — riavvia Cursor e riprova una volta |
|
||||
|
||||
**Password:** se hai cambiato quella iniziale, usa quella attuale (es. admin `daniele` non usa più `daniele`/`daniele`). Verifica su https://mcp.loogle.it/dashboard
|
||||
|
||||
**Se il browser non si apre:** prova la config con `"CLIENT_ID": "cursor"` sopra, oppure apri manualmente https://mcp.loogle.it/dashboard per testare le credenziali.
|
||||
|
||||
---
|
||||
|
||||
## Collegare Gemini
|
||||
|
||||
**Gemini CLI** (da terminale / PC):
|
||||
|
||||
1. Apri il file di configurazione MCP di Gemini (es. `~/.gemini/settings.json`)
|
||||
2. Aggiungi:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"loogle-mcp": {
|
||||
"url": "https://mcp.loogle.it/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Al primo avvio completa il login OAuth nel browser con le tue credenziali
|
||||
|
||||
L’app Gemini mobile potrebbe non supportare ancora MCP remoto; in quel caso usa ChatGPT o Claude.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard web
|
||||
|
||||
Indirizzo: **https://mcp.loogle.it/dashboard**
|
||||
|
||||
Da qui puoi:
|
||||
|
||||
- Vedere l’elenco dei **tuoi progetti**
|
||||
- **Cambiare password**
|
||||
- Consultare le **ultime azioni** che l’AI ha fatto (audit log)
|
||||
|
||||
---
|
||||
|
||||
## Come usarlo nel quotidiano
|
||||
|
||||
### 1. Crea un progetto per ogni tema importante
|
||||
|
||||
Chiedi all’AI:
|
||||
|
||||
> «Crea un progetto chiamato "Rinnovo bagno" con tag casa, 2026»
|
||||
|
||||
(Usa il tool `create_project` — l’AI lo farà automaticamente se il connector è attivo.)
|
||||
|
||||
### 2. Lavora e fai salvare la memoria
|
||||
|
||||
A fine sessione:
|
||||
|
||||
> «Salva in save_context un riepilogo di quello che abbiamo deciso oggi, nel progetto rinnovo-bagno»
|
||||
|
||||
La prossima volta l’AI potrà rileggere tutto con `get_project_context`.
|
||||
|
||||
### 3. Cerca documenti di casa
|
||||
|
||||
> «Cerca in search_knowledge la bolletta luce dell’ultimo trimestre»
|
||||
|
||||
> «Trova nel archivio documenti il manuale della caldaia»
|
||||
|
||||
(I documenti devono essere già in Paperless su docs.loogle.it.)
|
||||
|
||||
### 4. Cerca tra i tuoi appunti passati
|
||||
|
||||
> «search_context: cosa avevamo deciso sul parquet?»
|
||||
|
||||
### 5. Codice e runbook su Gitea
|
||||
|
||||
> «list_repos: quali repository ho su git.loogle.it?»
|
||||
|
||||
> «get_file su daniele/rete ha/RUNBOOK-failover.md»
|
||||
|
||||
> «search_code tier-b nel repo daniele/rete»
|
||||
|
||||
### 6. Progetto MCP collegato al codice
|
||||
|
||||
> «create_project "Infra Rete" con gitea_repo daniele/rete e seed_from_gitea true»
|
||||
|
||||
> «link_project_repo sul progetto homelab-loogle → daniele/rete»
|
||||
|
||||
> «get_project_context sul progetto attivo» — include README e elenco file in `docs/` dal repo collegato
|
||||
|
||||
> «search_gitea_knowledge: come funziona il failover tier-b?»
|
||||
|
||||
> «reindex_gitea_repo daniele/rete» — forza aggiornamento indicizzazione
|
||||
|
||||
### 7. Ricerca semantica su runbook Git (RAG)
|
||||
|
||||
> «search_gitea_knowledge failover su daniele/rete»
|
||||
|
||||
> «search_knowledge keepalived VIP» — cerca anche in Paperless e Gitea indicizzati
|
||||
|
||||
L'indexer in background indicizza `.md`, script e config dai repo collegati (vedi `docs/GITEA-TOKEN.md` sezione P3).
|
||||
|
||||
### 8. Casa, rete e Home Assistant (P5)
|
||||
|
||||
> «get_home_weather: che tempo fa a casa?»
|
||||
|
||||
> «get_network_overview: qualcosa è offline in rete?»
|
||||
|
||||
> «get_network_failover_status: com'è lo scenario failover?»
|
||||
|
||||
> «get_ha_entity switch.pompa_pozzo» — stato entità Home Assistant (solo lettura)
|
||||
|
||||
> «search_ha_entities caldaia»
|
||||
|
||||
### 9. Irrigazione e turni (P6)
|
||||
|
||||
> «get_irrigation_status: programma irrigazione oggi»
|
||||
|
||||
> «get_irrigation_zones: quali zone sono attive?»
|
||||
|
||||
> «get_my_shifts: i miei turni di lavoro questo mese»
|
||||
|
||||
> «list_turni_doctors» — elenco medici in Turni-Live
|
||||
|
||||
### 10. Ricerca storica app (P7)
|
||||
|
||||
> «search_apps_knowledge irrigazione prato ieri»
|
||||
|
||||
> «search_knowledge valvola pozzo» — include anche Irrigazione/Turni indicizzati
|
||||
|
||||
Dettagli tecnici e configurazione: `docs/APPS-INTEGRATION.md`.
|
||||
|
||||
### 11. Contesti condivisi con la famiglia
|
||||
|
||||
Alcuni progetti sono di tutti, per esempio **Stampa 3D famiglia — Bambu Lab X2D**: stampante,
|
||||
filamenti, piatti, costi e regole di lavoro sono gli stessi per chiunque progetti un oggetto.
|
||||
Compaiono in `list_projects` con `shared: true` e chiunque li può leggere e aggiornare.
|
||||
|
||||
> «Leggi il contesto condiviso della stampa 3D X2D e aiutami a progettare un portapenne»
|
||||
|
||||
> «Ho comprato 2 kg di PETG nero a 18 €/kg: aggiorna la dotazione nel contesto X2D»
|
||||
|
||||
Quando cambia qualcosa (acquisti, accessori, prezzi, regole) l'assistente aggiorna la sezione
|
||||
esistente invece di aggiungere note che la contraddicono: da quel momento vale per tutti.
|
||||
Ogni modifica registra chi l'ha fatta.
|
||||
|
||||
Per creare un nuovo contesto condiviso:
|
||||
|
||||
> «create_project "Viaggio estate 2027" con shared true»
|
||||
|
||||
Per condividere un tuo progetto esistente: *«share_project rinnovo-bagno»*. Solo chi l'ha
|
||||
condiviso (o Daniele) può archiviarlo o riportarlo personale.
|
||||
|
||||
---
|
||||
|
||||
## Cosa può e non può fare
|
||||
|
||||
| Può | Non può |
|
||||
|-----|---------|
|
||||
| Leggere i **tuoi** progetti | Vedere progetti di altri familiari |
|
||||
| Cercare documenti **famiglia** e **personali** (secondo tag Paperless) | Modificare Paperless o cancellare PDF |
|
||||
| Aggiungere testo alla **tua** memoria progetto | Accedere senza login OAuth |
|
||||
| Cercare semanticamente (capisce il significato, non solo parole esatte) | Funzionare senza internet verso casa* |
|
||||
| Leggere codice e issue da **Gitea** (repo a cui hai accesso) | Push/commit git via MCP (usa git normalmente) |
|
||||
| Meteo/rete da **Loogle Casa**, sensori **Home Assistant** (lettura) | Controllare luci/valvole via MCP (solo lettura HA) |
|
||||
| Stato **irrigazione** e **turni** (se configurati per te) | Modificare programmi irrigazione o turni via MCP |
|
||||
|
||||
\*Da fuori casa serve VPN WireGuard (`vpn.loogle.it`) o connessione alla rete di casa.
|
||||
|
||||
---
|
||||
|
||||
## Problemi comuni
|
||||
|
||||
| Problema | Cosa fare |
|
||||
|----------|-----------|
|
||||
| Login fallisce | Verifica username/password; reset da dashboard |
|
||||
| ChatGPT non trova il server | URL esatto: `https://mcp.loogle.it/mcp` (con https) |
|
||||
| «Knowledge vuota» | I documenti Paperless potrebbero non essere ancora indicizzati — chiedi a Daniele |
|
||||
| Token scaduto | Riconnetti il connector OAuth (disconnect + connect) |
|
||||
| Cursor «Unauthorized» | Clicca **Connect** una volta; login browser; usa config con `"CLIENT_ID": "cursor"` |
|
||||
| Sito non raggiungibile | Sei in VPN/rete casa? Prova https://mcp.loogle.it/health |
|
||||
|
||||
---
|
||||
|
||||
## Contatti
|
||||
|
||||
Per problemi tecnici (token Paperless, servizio down, nuovo progetto di gruppo): **Daniele**.
|
||||
|
||||
Per la password dimenticata: chiedi a Daniele (admin) o usa il dashboard se ricordi quella vecchia.
|
||||
|
||||
---
|
||||
|
||||
## Scheda rapida
|
||||
|
||||
```
|
||||
URL MCP: https://mcp.loogle.it/mcp
|
||||
Dashboard: https://mcp.loogle.it/dashboard
|
||||
Login: il tuo username (lucia / davide / luca)
|
||||
Primo passo: cambia password → collega ChatGPT o Claude → crea un progetto
|
||||
```
|
||||
@@ -1,157 +0,0 @@
|
||||
# Onboarding client AI — Loogle MCP Hub
|
||||
|
||||
Endpoint MCP: `https://mcp.loogle.it/mcp`
|
||||
|
||||
OAuth: al primo collegamento compare la pagina login Loogle. Usa le credenziali famiglia.
|
||||
|
||||
Password iniziale = username (es. `lucia` / `lucia`). Cambiala da https://mcp.loogle.it/dashboard
|
||||
|
||||
---
|
||||
|
||||
## Daniele (admin)
|
||||
|
||||
- Username: `daniele`
|
||||
- Tool admin: `reindex_document`, audit completo, revoca token refresh
|
||||
|
||||
## Lucia
|
||||
|
||||
- Username: `lucia`
|
||||
- Progetti isolati in `/mnt/ha-apps/mcp/context/lucia/`
|
||||
|
||||
## Davide
|
||||
|
||||
- Username: `davide`
|
||||
|
||||
## Luca
|
||||
|
||||
- Username: `luca`
|
||||
|
||||
---
|
||||
|
||||
## ChatGPT (Plus/Pro/Business)
|
||||
|
||||
1. Abilita **Developer Mode** nelle impostazioni ChatGPT
|
||||
2. Settings → Connectors → **Add MCP Connector**
|
||||
3. URL server: `https://mcp.loogle.it/mcp`
|
||||
4. Completa OAuth con le tue credenziali
|
||||
5. Abilita i tool desiderati nella conversazione
|
||||
|
||||
Redirect URI supportati (pre-registrati):
|
||||
- `https://chatgpt.com/connector_platform_oauth_redirect`
|
||||
- `https://chat.openai.com/connector_platform_oauth_redirect`
|
||||
|
||||
## Claude App / Claude Desktop
|
||||
|
||||
1. Settings → Connectors → **Add remote MCP server**
|
||||
2. URL: `https://mcp.loogle.it/mcp`
|
||||
3. Autenticazione OAuth al primo uso
|
||||
|
||||
Redirect URI: `https://claude.ai/api/mcp/auth_callback`
|
||||
|
||||
## Cursor IDE
|
||||
|
||||
Cursor supporta MCP remoto con **OAuth automatico** (Streamable HTTP).
|
||||
|
||||
### Configurazione
|
||||
|
||||
**Globale** (tutti i progetti): `~/.cursor/mcp.json`
|
||||
**Solo questo repo**: `.cursor/mcp.json` (già presente nel progetto loogle-mcp)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"loogle-mcp": {
|
||||
"url": "https://mcp.loogle.it/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Collegamento
|
||||
|
||||
1. Apri **Cursor Settings** → **Tools & MCP**
|
||||
2. Trova **loogle-mcp** → clic **Connect**
|
||||
3. Si apre il browser → login con le **tue** credenziali Loogle (`daniele`, `lucia`, …)
|
||||
4. Autorizza → Cursor torna con i tool attivi
|
||||
|
||||
Redirect OAuth Cursor (già registrati sul server):
|
||||
- `https://www.cursor.com/agents/mcp/oauth/callback` (web / Agents)
|
||||
- `http://localhost:8787/callback` (desktop app)
|
||||
|
||||
### Uso in chat
|
||||
|
||||
Chiedi all'agente di usare i tool MCP, ad esempio:
|
||||
|
||||
> «Usa whoami e list_projects per vedere i miei progetti Loogle»
|
||||
|
||||
Se il server risulta disconnesso: **Reconnect** da Tools & MCP.
|
||||
|
||||
---
|
||||
|
||||
## Gemini CLI
|
||||
|
||||
Aggiungi in `~/.gemini/settings.json` (o equivalente):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"loogle": {
|
||||
"url": "https://mcp.loogle.it/mcp",
|
||||
"transport": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Al primo avvio completa il flow OAuth nel browser.
|
||||
|
||||
## Cursor IDE
|
||||
|
||||
File `.cursor/mcp.json` nel progetto:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"loogle-mcp": {
|
||||
"url": "https://mcp.loogle.it/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool disponibili
|
||||
|
||||
| Tool | Descrizione |
|
||||
|------|-------------|
|
||||
| `ping` | Test connettività |
|
||||
| `whoami` | Utente e scope |
|
||||
| `list_projects` | Progetti utente |
|
||||
| `create_project` | Nuovo archivio contesto |
|
||||
| `get_project_context` | Legge memoria progetto |
|
||||
| `save_context` | Salva/appende memoria |
|
||||
| `search_context` | Ricerca semantica contesti |
|
||||
| `search_knowledge` | RAG su Paperless + contesti + Gitea indicizzati |
|
||||
| `search_gitea_knowledge` | RAG solo su file Gitea (runbook, markdown) |
|
||||
| `list_gitea_indexed_files` | File Gitea nel vector store |
|
||||
| `reindex_gitea_repo` | Re-indicizza un repo (admin/owner) |
|
||||
| `get_document` | Testo documento Paperless |
|
||||
| `list_recent_documents` | Ultimi doc indicizzati |
|
||||
| `reindex_document` | Solo admin |
|
||||
| `list_repos` | Repository Gitea accessibili |
|
||||
| `get_file` | Legge file da repo Gitea |
|
||||
| `search_code` | Cerca nel codice sorgente |
|
||||
| `list_issues` / `get_issue` | Issue su repo Gitea |
|
||||
| `create_issue` | Crea issue (scope gitea:write) |
|
||||
| `link_project_repo` | Collega/scollega repo Gitea a un progetto |
|
||||
|
||||
## Esempio prompt agente
|
||||
|
||||
> "Usa list_projects per vedere i miei progetti, poi get_project_context sul progetto attivo e search_knowledge per trovare documenti rilevanti su [argomento]."
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **401 su MCP**: token scaduto — riconnetti il connector OAuth
|
||||
- **Knowledge vuota**: verifica token Paperless e Ollama su DS920
|
||||
- **Dashboard**: https://mcp.loogle.it/dashboard
|
||||
@@ -1,108 +0,0 @@
|
||||
# Token Paperless per Loogle MCP
|
||||
|
||||
## Cosa NON usare: token «Applicazione social»
|
||||
|
||||
Nell'admin Django di Paperless (`/admin/socialaccount/socialtoken/`) compare il form **«Aggiungi token dell'applicazione social»** con campi Token, Token segreto, Scade il.
|
||||
|
||||
Questi token servono a **django-allauth** per il login OAuth web (Google, Microsoft, ecc.) su Paperless. **Non** sono token dell'API REST di Paperless e **non** vanno usati per Loogle MCP.
|
||||
|
||||
Non devi configurare nulla in quella schermata per il nostro hub MCP.
|
||||
|
||||
---
|
||||
|
||||
## Cosa usare: token API utente
|
||||
|
||||
Paperless espone un'API REST autenticata con header:
|
||||
|
||||
```
|
||||
Authorization: Token <token>
|
||||
```
|
||||
|
||||
Ogni utente Paperless ha il proprio token, legato ai permessi di quell'account (documenti visibili, tag, ecc.).
|
||||
|
||||
### Dove generarlo
|
||||
|
||||
Per **ogni** familiare con account Paperless (daniele, lucia, davide, luca):
|
||||
|
||||
1. Accedi a https://docs.loogle.it con quell'account
|
||||
2. Menu utente (in alto a destra) → **Profilo** / **Il mio profilo**
|
||||
3. Sezione **Token API** → pulsante freccia circolare (rigenera token)
|
||||
4. Copia il token nella riga corrispondente di `.env`
|
||||
|
||||
Alternativa admin: Django admin → **Token** (app `authtoken`), associato all'utente.
|
||||
|
||||
---
|
||||
|
||||
## Configurazione in Loogle MCP
|
||||
|
||||
Modifica `/home/daniely/docker/loogle-mcp/.env`:
|
||||
|
||||
### Opzione consigliata — token per utente (4 familiari)
|
||||
|
||||
```env
|
||||
PAPERLESS_URL=https://docs.loogle.it
|
||||
PAPERLESS_API_TOKEN_DANIELE=
|
||||
PAPERLESS_API_TOKEN_LUCIA=
|
||||
PAPERLESS_API_TOKEN_DAVIDE=
|
||||
PAPERLESS_API_TOKEN_LUCA=
|
||||
```
|
||||
|
||||
| Utente MCP | Variabile `.env` | Account Paperless |
|
||||
|------------|------------------|-------------------|
|
||||
| Daniele | `PAPERLESS_API_TOKEN_DANIELE` | utente admin / superuser |
|
||||
| Lucia | `PAPERLESS_API_TOKEN_LUCIA` | utente Lucia |
|
||||
| Davide | `PAPERLESS_API_TOKEN_DAVIDE` | utente Davide |
|
||||
| Luca | `PAPERLESS_API_TOKEN_LUCA` | utente Luca |
|
||||
|
||||
Righe vuote = utente saltato dall'indexer finché non inserisci il token.
|
||||
|
||||
### Opzione alternativa — un solo token admin
|
||||
|
||||
Se preferisci un unico account Paperless (es. Daniele superuser):
|
||||
|
||||
```env
|
||||
PAPERLESS_API_TOKEN=<token-daniele>
|
||||
```
|
||||
|
||||
Tutti gli utenti MCP useranno quel token in fallback.
|
||||
|
||||
### Opzione C — mappa JSON
|
||||
|
||||
```env
|
||||
PAPERLESS_API_TOKENS={"daniele":"...","lucia":"...","davide":"...","luca":"..."}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comportamento nel sistema
|
||||
|
||||
| Componente | Comportamento |
|
||||
|------------|---------------|
|
||||
| **Indexer** | Usa tutti i token configurati, unisce l'elenco documenti (deduplica per ID) |
|
||||
| **`search_knowledge`** | Cerca su tutto ciò che è stato indicizzato |
|
||||
| **`get_document`** | Usa il token dell'utente MCP loggato; se manca, fallback su `PAPERLESS_API_TOKEN` o token Daniele |
|
||||
|
||||
Con token separati, ogni familiare può scaricare via MCP solo i documenti visibili al proprio account Paperless.
|
||||
|
||||
---
|
||||
|
||||
## Applicare le modifiche
|
||||
|
||||
```bash
|
||||
cd /home/daniely/docker/loogle-mcp
|
||||
# modifica .env con i token reali
|
||||
docker compose restart loogle-mcp loogle-mcp-indexer
|
||||
docker logs loogle-mcp-indexer --tail 30
|
||||
```
|
||||
|
||||
Log atteso: indicizzazione da `daniele`, `lucia`, `davide`, `luca` (solo utenti con token impostato).
|
||||
|
||||
---
|
||||
|
||||
## Checklist onboarding Paperless
|
||||
|
||||
- [ ] Account Paperless creato per daniele, lucia, davide, luca (se non esistono)
|
||||
- [ ] Token API generato per ciascuno (Profilo → Token API)
|
||||
- [ ] Quattro righe compilate in `.env`
|
||||
- [ ] `docker compose restart loogle-mcp loogle-mcp-indexer`
|
||||
- [ ] Log indexer senza errori 401
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,9 +0,0 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.2
|
||||
python-multipart==0.0.20
|
||||
PyJWT==2.10.1
|
||||
cryptography==44.0.2
|
||||
httpx==0.28.1
|
||||
qdrant-client==1.13.3
|
||||
requests==2.32.3
|
||||
apscheduler==3.10.4
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
echo "Attendo health..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:8700/health >/dev/null; then
|
||||
echo "OK — loogle-mcp attivo su :8700"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Health check fallito — vedi docker logs loogle-mcp" >&2
|
||||
exit 1
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
mkdir -p /data /data/context
|
||||
|
||||
# Chiave SSH per thermal gate (permessi OpenSSH)
|
||||
if [ -f /run/secrets/ds920_ssh_key ]; then
|
||||
mkdir -p /root/.ssh
|
||||
cp /run/secrets/ds920_ssh_key /root/.ssh/ds920_key
|
||||
chmod 600 /root/.ssh/ds920_key
|
||||
export DS920_SSH_KEY=/root/.ssh/ds920_key
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Probe temperatura/load CPU DS920 — HTTP GET /thermal su :9191.
|
||||
|
||||
Avvio (sul DS920, utente daniely):
|
||||
nohup python3 ds920_thermal_probe.py >> /tmp/thermal-probe.log 2>&1 &
|
||||
|
||||
O via Task Scheduler Synology all'avvio.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
PORT = int(os.environ.get("THERMAL_PROBE_PORT", "9191"))
|
||||
HWMON = os.environ.get("THERMAL_HWMON", "/sys/class/hwmon/hwmon0")
|
||||
|
||||
|
||||
def read_metrics() -> dict:
|
||||
temps = []
|
||||
try:
|
||||
for name in sorted(os.listdir(HWMON)):
|
||||
if name.startswith("temp") and name.endswith("_input"):
|
||||
with open(os.path.join(HWMON, name), encoding="utf-8") as f:
|
||||
temps.append(int(f.read().strip()) / 1000.0)
|
||||
except OSError:
|
||||
pass
|
||||
load1, load5, load15 = os.getloadavg()
|
||||
nproc = os.cpu_count() or 4
|
||||
return {
|
||||
"ok": True,
|
||||
"host": os.uname().nodename,
|
||||
"cpu_temp_c": max(temps) if temps else None,
|
||||
"temps_c": temps,
|
||||
"load1": load1,
|
||||
"load5": load5,
|
||||
"load15": load15,
|
||||
"nproc": nproc,
|
||||
"cpu_target_load": round(nproc * 0.75, 2),
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt: str, *args) -> None: # noqa: A003
|
||||
return
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if self.path.split("?")[0] not in ("/", "/thermal", "/health"):
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
body = json.dumps(read_metrics()).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
||||
print(f"thermal probe listening on 0.0.0.0:{PORT}", flush=True)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,845 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Esporta un blocco giornaliero ricco su progetto MCP (default: homelab-loogle).
|
||||
|
||||
Fonti (massimo contesto utile, non dump grezzo):
|
||||
- transcript agenti Cursor (query utente + conclusioni assistente)
|
||||
- transcript Claude Code di Pi1 (locali) e Pi2 (copiati via rsync in ~/.cache/claude-transcripts/)
|
||||
- audit_log tool MCP del giorno
|
||||
- commit git recenti in repo tipici (rete, loogle-mcp)
|
||||
- piani Cursor `~/.cursor/plans/*.plan.md` → artifacts/plans/ + indice in context.md
|
||||
|
||||
Idempotente: marker <!-- daily-digest:YYYY-MM-DD --> — con --force sostituisce il blocco del giorno.
|
||||
|
||||
Esempi:
|
||||
python3 scripts/export_daily_agent_digest.py
|
||||
python3 scripts/export_daily_agent_digest.py --date 2026-09-03 --force
|
||||
python3 scripts/export_daily_agent_digest.py --dry-run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
MARKER_RE = re.compile(r"<!--\s*daily-digest:(\d{4}-\d{2}-\d{2})\s*-->")
|
||||
USER_QUERY_RE = re.compile(
|
||||
r"<timestamp>(.*?)</timestamp>\s*<user_query>\s*(.*?)\s*</user_query>",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
TS_PREFIX_RE = re.compile(r"^\[?\d{4}-\d{2}-\d{2}")
|
||||
FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
|
||||
|
||||
DEFAULT_TRANSCRIPT_ROOTS = [
|
||||
Path.home() / ".cursor/projects/home-daniely/agent-transcripts",
|
||||
Path.home() / ".cursor/projects/home-daniely-docker-loogle-mcp/agent-transcripts",
|
||||
]
|
||||
DEFAULT_GIT_REPOS = [
|
||||
Path.home() / "rete",
|
||||
Path.home() / "docker/loogle-mcp",
|
||||
]
|
||||
DEFAULT_PLANS_DIR = Path.home() / ".cursor/plans"
|
||||
# Claude Code: <root>/<progetto>/<sessione>.jsonl. Pi2 viene copiato qui prima della lettura.
|
||||
CLAUDE_LOCAL_ROOT = Path.home() / ".claude/projects"
|
||||
CLAUDE_REMOTES = [("pi2", "daniely@192.168.128.81")]
|
||||
CLAUDE_CACHE = Path.home() / ".cache/claude-transcripts"
|
||||
SYSTEM_TAG_RE = re.compile(r"<(system-reminder|command-[a-z-]+|local-command-[a-z-]+)[^>]*>.*?</\1>", re.DOTALL)
|
||||
PASTED_TAG_RE = re.compile(r"</?pasted_content[^>]*>")
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
env_path = ROOT / ".env"
|
||||
if not env_path.is_file():
|
||||
return
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value.strip().strip("'").strip('"')
|
||||
|
||||
|
||||
def _configure_paths() -> None:
|
||||
if Path("/.dockerenv").is_file() or (Path("/data").is_dir() and os.access("/data", os.W_OK)):
|
||||
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
|
||||
os.environ.setdefault("MCP_CONTEXT_ROOT", "/data/context")
|
||||
os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
|
||||
return
|
||||
os.environ.setdefault("MCP_DB", str(ROOT / "data" / "loogle_mcp.db"))
|
||||
os.environ.setdefault("MCP_CONTEXT_ROOT", "/mnt/ha-apps/mcp/context")
|
||||
os.environ.setdefault("MCP_VECTOR_FALLBACK", str(ROOT / "data" / "vector_fallback.db"))
|
||||
|
||||
|
||||
def _parse_day(s: Optional[str]) -> date:
|
||||
if not s:
|
||||
return date.today()
|
||||
return date.fromisoformat(s)
|
||||
|
||||
|
||||
def _day_bounds(day: date) -> tuple[datetime, datetime]:
|
||||
start = datetime.combine(day, datetime.min.time())
|
||||
end = start + timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
|
||||
def _is_noise_query(q: str) -> bool:
|
||||
q = (q or "").strip()
|
||||
if len(q) < 12:
|
||||
return True
|
||||
low = q.lower()
|
||||
if q.startswith("<") or q.startswith("[REDACTED]"):
|
||||
return True
|
||||
noise_prefixes = (
|
||||
"you are ",
|
||||
"you have access",
|
||||
"start multitasking",
|
||||
"briefly inform the user",
|
||||
"perform any necessary follow-up",
|
||||
"the following task has finished",
|
||||
"<mcp_",
|
||||
"<dynamic_tools>",
|
||||
"<agent_transcripts>",
|
||||
)
|
||||
return any(low.startswith(p) or p in low[:80] for p in noise_prefixes)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
text = re.sub(r"\s+", " ", (text or "").strip())
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _extract_text_blocks(message: Any) -> list[str]:
|
||||
if not isinstance(message, dict):
|
||||
return []
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return [content]
|
||||
out: list[str] = []
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
t = part.get("text") or ""
|
||||
if t and t != "[REDACTED]":
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_event_time(raw: str, file_mtime: float) -> Optional[datetime]:
|
||||
raw = (raw or "").strip()
|
||||
cleaned = re.sub(r"\s*\([^)]*\)\s*$", "", raw).strip()
|
||||
for fmt in (
|
||||
"%A, %b %d, %Y, %I:%M %p",
|
||||
"%A, %B %d, %Y, %I:%M %p",
|
||||
"%Y-%m-%dT%H:%M:%S%z",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
):
|
||||
try:
|
||||
return datetime.strptime(cleaned, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return datetime.fromtimestamp(file_mtime)
|
||||
|
||||
|
||||
def collect_transcripts(day: date, roots: list[Path], max_chats: int = 25) -> list[dict]:
|
||||
start, end = _day_bounds(day)
|
||||
chats: list[dict] = []
|
||||
files: list[Path] = []
|
||||
for root in roots:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for path in root.rglob("*.jsonl"):
|
||||
if "subagents" in path.parts:
|
||||
continue
|
||||
files.append(path)
|
||||
|
||||
for path in sorted(files, key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
mtime = path.stat().st_mtime
|
||||
# quick skip: file entirely older than day-1 or newer handled by content
|
||||
if datetime.fromtimestamp(mtime) < start - timedelta(days=2):
|
||||
continue
|
||||
user_queries: list[str] = []
|
||||
assistant_tails: list[str] = []
|
||||
paths_touched: Counter[str] = Counter()
|
||||
day_hit = False
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
role = obj.get("role")
|
||||
for text in _extract_text_blocks(obj.get("message") or {}):
|
||||
if role == "user":
|
||||
for ts_raw, query in USER_QUERY_RE.findall(text):
|
||||
when = _parse_event_time(ts_raw, mtime)
|
||||
if when and start <= when < end:
|
||||
day_hit = True
|
||||
q = _truncate(query, 280)
|
||||
if not _is_noise_query(q):
|
||||
user_queries.append(q)
|
||||
# bare user text without wrapper — solo se breve messaggio umano
|
||||
if (
|
||||
"<user_query>" not in text
|
||||
and start.timestamp() <= mtime < end.timestamp()
|
||||
and len(text) > 40
|
||||
and not _is_noise_query(text)
|
||||
and "<" not in text[:20]
|
||||
):
|
||||
day_hit = True
|
||||
user_queries.append(_truncate(text, 280))
|
||||
elif role == "assistant":
|
||||
if start.timestamp() <= mtime < end.timestamp() or day_hit:
|
||||
if (
|
||||
len(text) > 80
|
||||
and not text.startswith("[REDACTED]")
|
||||
and not text.startswith("<")
|
||||
and "tool_use" not in text[:40]
|
||||
):
|
||||
# preferisci paragrafi conclusivi (markdown grassetto / verdetto)
|
||||
assistant_tails.append(_truncate(text, 320))
|
||||
# tool paths
|
||||
msg = obj.get("message") or {}
|
||||
content = msg.get("content") if isinstance(msg, dict) else None
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if not isinstance(part, dict) or part.get("type") != "tool_use":
|
||||
continue
|
||||
inp = part.get("input") or {}
|
||||
for key in ("path", "target_notebook", "file_path"):
|
||||
if key in inp and isinstance(inp[key], str):
|
||||
paths_touched[inp[key]] += 1
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if not day_hit and not user_queries:
|
||||
# include if file modified that day and has substance
|
||||
if not (start.timestamp() <= mtime < end.timestamp()):
|
||||
continue
|
||||
if not assistant_tails:
|
||||
continue
|
||||
|
||||
# dedupe queries
|
||||
seen = set()
|
||||
uniq_q = []
|
||||
for q in user_queries:
|
||||
if q not in seen:
|
||||
seen.add(q)
|
||||
uniq_q.append(q)
|
||||
chats.append(
|
||||
{
|
||||
"id": path.parent.name if path.parent.name != "agent-transcripts" else path.stem,
|
||||
"queries": uniq_q[:8],
|
||||
"conclusions": assistant_tails[-3:],
|
||||
"paths": [p for p, _ in paths_touched.most_common(8)],
|
||||
}
|
||||
)
|
||||
if len(chats) >= max_chats:
|
||||
break
|
||||
return chats
|
||||
|
||||
|
||||
def sync_claude_remotes(remotes: list[tuple[str, str]], cache: Path) -> list[tuple[str, Path]]:
|
||||
"""Copia i transcript Claude Code dei nodi remoti in cache; un nodo giù non blocca il digest."""
|
||||
roots: list[tuple[str, Path]] = []
|
||||
for name, host in remotes:
|
||||
dest = cache / name
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
"rsync", "-a", "--timeout=30", "--prune-empty-dirs",
|
||||
"-e", "ssh -o BatchMode=yes -o ConnectTimeout=6",
|
||||
"--include=*/", "--exclude=subagents/", "--include=*.jsonl", "--exclude=*",
|
||||
f"{host}:.claude/projects/", f"{dest}/",
|
||||
]
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
||||
if r.returncode != 0:
|
||||
print(f"# claude {name}: rsync rc={r.returncode} {r.stderr.strip()[:200]} (uso la copia precedente)",
|
||||
file=sys.stderr)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
print(f"# claude {name}: rsync non riuscito: {e} (uso la copia precedente)", file=sys.stderr)
|
||||
roots.append((name, dest))
|
||||
return roots
|
||||
|
||||
|
||||
def _claude_text(content: Any) -> list[str]:
|
||||
if isinstance(content, str):
|
||||
return [content]
|
||||
out: list[str] = []
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "text" and part.get("text"):
|
||||
out.append(part["text"])
|
||||
return out
|
||||
|
||||
|
||||
def _clean_claude_user(text: str) -> str:
|
||||
text = SYSTEM_TAG_RE.sub(" ", text)
|
||||
text = PASTED_TAG_RE.sub(" ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def collect_claude_transcripts(day: date, roots: list[tuple[str, Path]], max_chats: int = 25) -> list[dict]:
|
||||
"""Sessioni Claude Code con almeno un messaggio umano nel giorno (ora locale)."""
|
||||
start, end = _day_bounds(day)
|
||||
files: list[tuple[str, Path]] = []
|
||||
for host, root in roots:
|
||||
if root.is_dir():
|
||||
files.extend((host, p) for p in root.glob("*/*.jsonl"))
|
||||
chats: list[dict] = []
|
||||
for host, path in sorted(files, key=lambda hp: hp[1].stat().st_mtime, reverse=True):
|
||||
if datetime.fromtimestamp(path.stat().st_mtime) < start:
|
||||
continue
|
||||
title = ""
|
||||
queries: list[str] = []
|
||||
tails: list[str] = []
|
||||
paths_touched: Counter[str] = Counter()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
kind = obj.get("type")
|
||||
if kind == "custom-title":
|
||||
title = obj.get("customTitle") or title
|
||||
continue
|
||||
if kind not in ("user", "assistant") or obj.get("isSidechain") or obj.get("isMeta"):
|
||||
continue
|
||||
try:
|
||||
when = datetime.fromisoformat(obj["timestamp"].replace("Z", "+00:00")).astimezone().replace(tzinfo=None)
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
if not (start <= when < end):
|
||||
continue
|
||||
msg = obj.get("message") or {}
|
||||
content = msg.get("content") if isinstance(msg, dict) else None
|
||||
if kind == "user":
|
||||
if obj.get("turnOrigin") not in (None, "human"):
|
||||
continue
|
||||
for t in _claude_text(content):
|
||||
q = _truncate(_clean_claude_user(t), 280)
|
||||
if q and not _is_noise_query(q):
|
||||
queries.append(q)
|
||||
else:
|
||||
for t in _claude_text(content):
|
||||
if len(t) > 80:
|
||||
tails.append(_truncate(t, 320))
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "tool_use":
|
||||
inp = part.get("input") or {}
|
||||
for key in ("file_path", "path"):
|
||||
if isinstance(inp.get(key), str):
|
||||
paths_touched[inp[key]] += 1
|
||||
except OSError:
|
||||
continue
|
||||
if not queries:
|
||||
continue
|
||||
seen: set[str] = set()
|
||||
uniq = [q for q in queries if not (q in seen or seen.add(q))]
|
||||
chats.append({
|
||||
"id": path.stem,
|
||||
"source": f"claude-{host}",
|
||||
"title": title,
|
||||
"queries": uniq[:8],
|
||||
"conclusions": tails[-3:],
|
||||
"paths": [p for p, _ in paths_touched.most_common(8)],
|
||||
})
|
||||
if len(chats) >= max_chats:
|
||||
break
|
||||
return chats
|
||||
|
||||
|
||||
def collect_audit(day: date, db_path: Path) -> dict:
|
||||
if not db_path.is_file():
|
||||
return {"tools": [], "total": 0}
|
||||
start = day.isoformat()
|
||||
end = (day + timedelta(days=1)).isoformat()
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"SELECT tool_name, COUNT(*) AS n FROM audit_log"
|
||||
" WHERE created_at >= ? AND created_at < ? AND username=?"
|
||||
" GROUP BY tool_name ORDER BY n DESC",
|
||||
(start, end, "daniele"),
|
||||
).fetchall()
|
||||
samples = conn.execute(
|
||||
"SELECT created_at, tool_name, detail FROM audit_log"
|
||||
" WHERE created_at >= ? AND created_at < ? AND username=?"
|
||||
" ORDER BY id DESC LIMIT 15",
|
||||
(start, end, "daniele"),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return {
|
||||
"tools": [(r["tool_name"], r["n"]) for r in rows],
|
||||
"total": sum(r["n"] for r in rows),
|
||||
"samples": [
|
||||
{
|
||||
"at": r["created_at"],
|
||||
"tool": r["tool_name"],
|
||||
"detail": _truncate(r["detail"] or "", 120),
|
||||
}
|
||||
for r in samples
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def collect_git(day: date, repos: list[Path], limit: int = 12) -> list[str]:
|
||||
since = day.isoformat()
|
||||
until = (day + timedelta(days=1)).isoformat()
|
||||
lines: list[str] = []
|
||||
for repo in repos:
|
||||
if not (repo / ".git").exists():
|
||||
continue
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(repo),
|
||||
"log",
|
||||
f"--since={since}",
|
||||
f"--until={until}",
|
||||
"--pretty=format:%h %s",
|
||||
f"-n{limit}",
|
||||
],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=15,
|
||||
).strip()
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
continue
|
||||
if out:
|
||||
for line in out.splitlines():
|
||||
lines.append(f"{repo.name}: {line}")
|
||||
return lines[:limit]
|
||||
|
||||
|
||||
def _parse_plan_file(path: Path) -> dict:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
name = path.stem
|
||||
overview = ""
|
||||
todos_total = 0
|
||||
todos_done = 0
|
||||
m = FRONTMATTER_RE.match(text)
|
||||
if m:
|
||||
fm = m.group(1)
|
||||
nm = re.search(r"^name:\s*(.+)$", fm, re.M)
|
||||
if nm:
|
||||
name = nm.group(1).strip().strip("\"'")
|
||||
ov = re.search(r"^overview:\s*(.+)$", fm, re.M)
|
||||
if ov:
|
||||
overview = ov.group(1).strip().strip("\"'")
|
||||
# overview può essere su più righe YAML quoted — fallback grezzo
|
||||
if overview.startswith("|") or not overview:
|
||||
ov2 = re.search(r"^overview:\s*[>|]?\s*\n((?:[ \t]+.+\n)+)", fm, re.M)
|
||||
if ov2:
|
||||
overview = " ".join(line.strip() for line in ov2.group(1).splitlines())
|
||||
statuses = re.findall(r"^\s+status:\s*(\w+)", fm, re.M)
|
||||
todos_total = len(statuses)
|
||||
todos_done = sum(1 for s in statuses if s == "completed")
|
||||
mtime = datetime.fromtimestamp(path.stat().st_mtime)
|
||||
return {
|
||||
"file": path.name,
|
||||
"name": name,
|
||||
"overview": _truncate(overview, 220),
|
||||
"todos_total": todos_total,
|
||||
"todos_done": todos_done,
|
||||
"mtime": mtime,
|
||||
"text": text,
|
||||
"path": path,
|
||||
}
|
||||
|
||||
|
||||
def collect_and_sync_plans(
|
||||
day: date,
|
||||
plans_dir: Path,
|
||||
dest_dir: Path,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
"""Copia tutti i .plan.md in artifacts/plans/; ritorna catalogo + aggiornati nel giorno."""
|
||||
start, end = _day_bounds(day)
|
||||
plans: list[dict] = []
|
||||
if plans_dir.is_dir():
|
||||
for path in sorted(plans_dir.glob("*.plan.md")):
|
||||
try:
|
||||
plans.append(_parse_plan_file(path))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
copied = 0
|
||||
updated_today: list[dict] = []
|
||||
if not dry_run:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
for plan in plans:
|
||||
target = dest_dir / plan["file"]
|
||||
shutil.copy2(plan["path"], target)
|
||||
copied += 1
|
||||
if start <= plan["mtime"] < end:
|
||||
updated_today.append(plan)
|
||||
# INDEX.md per lettura umana / futuri tool
|
||||
idx_lines = [
|
||||
"# Piani Cursor (sync)",
|
||||
"",
|
||||
f"_Aggiornato {datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')} "
|
||||
f"da `~/.cursor/plans` → `artifacts/plans/`._",
|
||||
"",
|
||||
]
|
||||
for plan in sorted(plans, key=lambda p: p["mtime"], reverse=True):
|
||||
prog = (
|
||||
f"{plan['todos_done']}/{plan['todos_total']}"
|
||||
if plan["todos_total"]
|
||||
else "?"
|
||||
)
|
||||
idx_lines.append(
|
||||
f"- **{plan['name']}** (`{plan['file']}`, todos {prog}, "
|
||||
f"mtime {plan['mtime'].date().isoformat()})"
|
||||
)
|
||||
if plan["overview"]:
|
||||
idx_lines.append(f" - {plan['overview']}")
|
||||
idx_lines.append("")
|
||||
(dest_dir / "INDEX.md").write_text("\n".join(idx_lines), encoding="utf-8")
|
||||
else:
|
||||
updated_today = [p for p in plans if start <= p["mtime"] < end]
|
||||
|
||||
return {
|
||||
"plans": plans,
|
||||
"copied": copied,
|
||||
"updated_today": updated_today,
|
||||
"dest": str(dest_dir),
|
||||
}
|
||||
|
||||
|
||||
def upsert_plans_index_section(context_md: str, plans: list[dict]) -> str:
|
||||
"""Sezione stabile in context.md (non nel digest giornaliero) con indice piani."""
|
||||
begin = "<!-- BEGIN CURSOR PLANS -->"
|
||||
end = "<!-- END CURSOR PLANS -->"
|
||||
lines = [
|
||||
begin,
|
||||
"## Piani Cursor (indice sync)",
|
||||
"",
|
||||
"_File completi in `artifacts/plans/`. Qui solo indice per `get_project_context`._",
|
||||
"",
|
||||
]
|
||||
if not plans:
|
||||
lines.append("- Nessun piano in `~/.cursor/plans`.")
|
||||
else:
|
||||
for plan in sorted(plans, key=lambda p: p["mtime"], reverse=True):
|
||||
prog = (
|
||||
f"{plan['todos_done']}/{plan['todos_total']} done"
|
||||
if plan["todos_total"]
|
||||
else "n/d"
|
||||
)
|
||||
lines.append(
|
||||
f"- **{plan['name']}** — {prog} — `{plan['file']}` "
|
||||
f"({plan['mtime'].date().isoformat()})"
|
||||
)
|
||||
if plan["overview"]:
|
||||
lines.append(f" - {plan['overview']}")
|
||||
lines.extend(["", end, ""])
|
||||
block = "\n".join(lines)
|
||||
|
||||
if begin in context_md and end in context_md:
|
||||
pattern = re.compile(
|
||||
re.escape(begin) + r".*?" + re.escape(end),
|
||||
re.DOTALL,
|
||||
)
|
||||
return pattern.sub(block.strip(), context_md)
|
||||
# inserisci prima dei daily digests se presenti
|
||||
dig = "<!-- BEGIN DAILY DIGESTS -->"
|
||||
if dig in context_md:
|
||||
head, tail = context_md.split(dig, 1)
|
||||
return head.rstrip() + "\n\n" + block + "\n" + dig + tail
|
||||
return context_md.rstrip() + "\n\n" + block
|
||||
|
||||
|
||||
def build_markdown(
|
||||
day: date,
|
||||
chats: list[dict],
|
||||
audit: dict,
|
||||
git_lines: list[str],
|
||||
project_id: str,
|
||||
plans_updated: Optional[list[dict]] = None,
|
||||
) -> str:
|
||||
lines: list[str] = [
|
||||
f"<!-- daily-digest:{day.isoformat()} -->",
|
||||
f"## Digest agenti {day.isoformat()} — `{project_id}`",
|
||||
"",
|
||||
f"_Generato automaticamente da `export_daily_agent_digest.py` "
|
||||
f"({datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')})._",
|
||||
"",
|
||||
]
|
||||
|
||||
lines.append("### Chat / agenti (Cursor + Claude Code)")
|
||||
if not chats:
|
||||
lines.append("- Nessun transcript rilevante per questo giorno.")
|
||||
else:
|
||||
lines.append(f"- Sessioni considerate: **{len(chats)}**")
|
||||
for i, chat in enumerate(chats, 1):
|
||||
title = chat.get("title") or (chat["queries"][0] if chat["queries"] else chat["id"])
|
||||
lines.append(f"{i}. **{_truncate(title, 120)}** `[{chat.get('source', 'cursor')} {chat['id'][:8]}]`")
|
||||
for q in chat["queries"][(0 if chat.get("title") else 1):4]:
|
||||
lines.append(f" - Q: {_truncate(q, 160)}")
|
||||
for c in chat["conclusions"][-2:]:
|
||||
lines.append(f" - → {_truncate(c, 200)}")
|
||||
if chat["paths"]:
|
||||
short_paths = ", ".join(_truncate(p, 60) for p in chat["paths"][:5])
|
||||
lines.append(f" - File: `{short_paths}`")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Tool MCP usati")
|
||||
if not audit.get("total"):
|
||||
lines.append("- Nessuna chiamata tool in audit_log.")
|
||||
else:
|
||||
lines.append(f"- Totale chiamate: **{audit['total']}**")
|
||||
top = ", ".join(f"`{name}`×{n}" for name, n in audit["tools"][:10])
|
||||
lines.append(f"- Top: {top}")
|
||||
for s in (audit.get("samples") or [])[:8]:
|
||||
lines.append(f" - {s['at']} `{s['tool']}` {s['detail']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Commit git (homelab)")
|
||||
if not git_lines:
|
||||
lines.append("- Nessun commit nel giorno.")
|
||||
else:
|
||||
for g in git_lines:
|
||||
lines.append(f"- `{g}`")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Piani Cursor aggiornati oggi")
|
||||
plans_updated = plans_updated or []
|
||||
if not plans_updated:
|
||||
lines.append("- Nessun `.plan.md` modificato in questa data.")
|
||||
else:
|
||||
for plan in plans_updated:
|
||||
prog = (
|
||||
f"{plan['todos_done']}/{plan['todos_total']}"
|
||||
if plan["todos_total"]
|
||||
else "?"
|
||||
)
|
||||
lines.append(
|
||||
f"- **{plan['name']}** (`artifacts/plans/{plan['file']}`, todos {prog})"
|
||||
)
|
||||
if plan["overview"]:
|
||||
lines.append(f" - {plan['overview']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Per Claude / prossimi agenti")
|
||||
lines.append(
|
||||
"- Usa questo blocco come memoria del giorno; per dettagli codice preferisci "
|
||||
"`search_gitea_knowledge` / `get_file` sui path citati."
|
||||
)
|
||||
lines.append(
|
||||
"- Piani completi: sezione **Piani Cursor** in context.md + file in `artifacts/plans/`."
|
||||
)
|
||||
lines.append(
|
||||
"- Non ripetere setup già conclusi; riparti da decisioni e next step qui sopra."
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def upsert_daily_block(context_md: str, day: date, block: str) -> str:
|
||||
"""Rimuove eventuale blocco del giorno e inserisce il nuovo in cima alla sezione digest."""
|
||||
pattern = re.compile(
|
||||
rf"<!--\s*daily-digest:{day.isoformat()}\s*-->.*?"
|
||||
rf"(?=<!--\s*daily-digest:\d{{4}}-\d{{2}}-\d{{2}}\s*-->|<!--\s*END DAILY DIGESTS\s*-->|\Z)",
|
||||
re.DOTALL,
|
||||
)
|
||||
context_md = pattern.sub("", context_md)
|
||||
|
||||
begin = "<!-- BEGIN DAILY DIGESTS -->"
|
||||
end = "<!-- END DAILY DIGESTS -->"
|
||||
if begin not in context_md:
|
||||
context_md = context_md.rstrip() + f"\n\n{begin}\n\n{end}\n"
|
||||
|
||||
# Assicura END
|
||||
if end not in context_md:
|
||||
context_md = context_md.rstrip() + f"\n\n{end}\n"
|
||||
|
||||
head, rest = context_md.split(begin, 1)
|
||||
# rest inizia dopo BEGIN; togli END temporaneamente dalla porzione digest
|
||||
if end in rest:
|
||||
mid, tail = rest.split(end, 1)
|
||||
else:
|
||||
mid, tail = rest, ""
|
||||
mid = mid.strip()
|
||||
new_mid = block.strip() + ("\n\n" + mid if mid else "")
|
||||
return head.rstrip() + f"\n\n{begin}\n\n{new_mid}\n\n{end}" + tail
|
||||
|
||||
|
||||
def has_daily_block(context_md: str, day: date) -> bool:
|
||||
return f"<!-- daily-digest:{day.isoformat()} -->" in context_md
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Digest giornaliero agenti → MCP context")
|
||||
parser.add_argument("--date", help="YYYY-MM-DD (default: oggi)")
|
||||
parser.add_argument("--user", default="daniele")
|
||||
parser.add_argument("--project", default="homelab-loogle")
|
||||
parser.add_argument("--force", action="store_true", help="Sostituisci blocco del giorno se già presente")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--no-index", action="store_true", help="Non aggiornare Qdrant ctx_*")
|
||||
parser.add_argument("--max-chats", type=int, default=20)
|
||||
parser.add_argument("--no-claude", action="store_true", help="Non leggere i transcript Claude Code")
|
||||
parser.add_argument(
|
||||
"--no-plans",
|
||||
action="store_true",
|
||||
help="Non sincronizzare ~/.cursor/plans",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
_load_dotenv()
|
||||
_configure_paths()
|
||||
day = _parse_day(args.date)
|
||||
|
||||
proj = Path(os.environ["MCP_CONTEXT_ROOT"]) / args.user / "projects" / args.project
|
||||
plans_dest = proj / "artifacts" / "plans"
|
||||
|
||||
plans_info: dict = {"plans": [], "copied": 0, "updated_today": [], "dest": str(plans_dest)}
|
||||
if not args.no_plans:
|
||||
plans_info = collect_and_sync_plans(
|
||||
day,
|
||||
DEFAULT_PLANS_DIR,
|
||||
plans_dest,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
chats = collect_transcripts(day, DEFAULT_TRANSCRIPT_ROOTS, max_chats=args.max_chats)
|
||||
if not args.no_claude:
|
||||
claude_roots = [("pi1", CLAUDE_LOCAL_ROOT)] + sync_claude_remotes(CLAUDE_REMOTES, CLAUDE_CACHE)
|
||||
chats += collect_claude_transcripts(day, claude_roots, max_chats=args.max_chats)
|
||||
audit = collect_audit(day, Path(os.environ["MCP_DB"]))
|
||||
git_lines = collect_git(day, DEFAULT_GIT_REPOS)
|
||||
block = build_markdown(
|
||||
day,
|
||||
chats,
|
||||
audit,
|
||||
git_lines,
|
||||
args.project,
|
||||
plans_updated=plans_info.get("updated_today") or [],
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
print(block)
|
||||
if plans_info.get("plans"):
|
||||
print("\n# plans index preview", file=sys.stderr)
|
||||
for p in plans_info["plans"][:5]:
|
||||
print(f"# - {p['name']} ({p['file']})", file=sys.stderr)
|
||||
print(
|
||||
f"\n# dry-run chats={len(chats)} audit={audit.get('total', 0)} "
|
||||
f"git={len(git_lines)} plans={len(plans_info.get('plans') or [])} "
|
||||
f"plans_today={len(plans_info.get('updated_today') or [])} chars={len(block)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
from app.context import store as context_store
|
||||
from app.db import init_db
|
||||
from app.knowledge import indexer
|
||||
|
||||
init_db()
|
||||
data = context_store.get_project_context(args.user, args.project, session_limit=1, include_gitea=False)
|
||||
existing = data.get("context_md") or ""
|
||||
|
||||
# I piani si sincronizzano sempre; il digest può essere skippato se già presente
|
||||
existing = upsert_plans_index_section(existing, plans_info.get("plans") or [])
|
||||
|
||||
digest_skipped = False
|
||||
if has_daily_block(existing, day) and not args.force:
|
||||
digest_skipped = True
|
||||
new_md = existing
|
||||
print(f"SKIP digest: {day.isoformat()} già presente (usa --force); plans sync ok")
|
||||
else:
|
||||
new_md = upsert_daily_block(existing, day, block)
|
||||
|
||||
ctx_path = proj / "context.md"
|
||||
meta_path = proj / "meta.json"
|
||||
sessions = proj / "sessions"
|
||||
sessions.mkdir(parents=True, exist_ok=True)
|
||||
ctx_path.write_text(new_md, encoding="utf-8")
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
meta["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
snap = None
|
||||
if not digest_skipped:
|
||||
snap = sessions / f"{day.isoformat()}-daily.md"
|
||||
snap.write_text(block, encoding="utf-8")
|
||||
|
||||
if not args.no_index:
|
||||
try:
|
||||
if not digest_skipped:
|
||||
indexer.index_context_snippet(
|
||||
args.user,
|
||||
args.project,
|
||||
block,
|
||||
f"Digest {day.isoformat()} — {meta.get('title', args.project)}",
|
||||
snippet_id=f"daily-{day.isoformat()}",
|
||||
)
|
||||
# indicizza ogni piano (testo ridotto: name+overview+body troncato)
|
||||
for plan in plans_info.get("plans") or []:
|
||||
body = plan["text"]
|
||||
if len(body) > 12000:
|
||||
body = body[:12000] + "\n…[troncato]"
|
||||
indexer.index_context_snippet(
|
||||
args.user,
|
||||
args.project,
|
||||
f"# Piano Cursor: {plan['name']}\n\n{plan['overview']}\n\n{body}",
|
||||
f"Plan: {plan['name']}",
|
||||
snippet_id=f"plan-{plan['file']}",
|
||||
)
|
||||
print("indexed: ok")
|
||||
except Exception as exc:
|
||||
print(f"indexed: skip ({exc})")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"day": day.isoformat(),
|
||||
"project": args.project,
|
||||
"digest_skipped": digest_skipped,
|
||||
"chats": len(chats),
|
||||
"audit_calls": audit.get("total", 0),
|
||||
"git_commits": len(git_lines),
|
||||
"plans_synced": plans_info.get("copied", 0),
|
||||
"plans_updated_today": len(plans_info.get("updated_today") or []),
|
||||
"chars": len(block),
|
||||
"context_chars": len(new_md),
|
||||
"session": str(snap) if snap else None,
|
||||
"plans_dest": plans_info.get("dest"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,721 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Esporta un blocco giornaliero ricco su progetto MCP (default: homelab-loogle).
|
||||
|
||||
Fonti (massimo contesto utile, non dump grezzo):
|
||||
- transcript agenti Cursor (query utente + conclusioni assistente)
|
||||
- audit_log tool MCP del giorno
|
||||
- commit git recenti in repo tipici (rete, loogle-mcp)
|
||||
- piani Cursor `~/.cursor/plans/*.plan.md` → artifacts/plans/ + indice in context.md
|
||||
|
||||
Idempotente: marker <!-- daily-digest:YYYY-MM-DD --> — con --force sostituisce il blocco del giorno.
|
||||
|
||||
Esempi:
|
||||
python3 scripts/export_daily_agent_digest.py
|
||||
python3 scripts/export_daily_agent_digest.py --date 2026-09-03 --force
|
||||
python3 scripts/export_daily_agent_digest.py --dry-run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
MARKER_RE = re.compile(r"<!--\s*daily-digest:(\d{4}-\d{2}-\d{2})\s*-->")
|
||||
USER_QUERY_RE = re.compile(
|
||||
r"<timestamp>(.*?)</timestamp>\s*<user_query>\s*(.*?)\s*</user_query>",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
TS_PREFIX_RE = re.compile(r"^\[?\d{4}-\d{2}-\d{2}")
|
||||
FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
|
||||
|
||||
DEFAULT_TRANSCRIPT_ROOTS = [
|
||||
Path.home() / ".cursor/projects/home-daniely/agent-transcripts",
|
||||
Path.home() / ".cursor/projects/home-daniely-docker-loogle-mcp/agent-transcripts",
|
||||
]
|
||||
DEFAULT_GIT_REPOS = [
|
||||
Path.home() / "rete",
|
||||
Path.home() / "docker/loogle-mcp",
|
||||
]
|
||||
DEFAULT_PLANS_DIR = Path.home() / ".cursor/plans"
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
env_path = ROOT / ".env"
|
||||
if not env_path.is_file():
|
||||
return
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value.strip().strip("'").strip('"')
|
||||
|
||||
|
||||
def _configure_paths() -> None:
|
||||
if Path("/.dockerenv").is_file() or (Path("/data").is_dir() and os.access("/data", os.W_OK)):
|
||||
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
|
||||
os.environ.setdefault("MCP_CONTEXT_ROOT", "/data/context")
|
||||
os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
|
||||
return
|
||||
os.environ.setdefault("MCP_DB", str(ROOT / "data" / "loogle_mcp.db"))
|
||||
os.environ.setdefault("MCP_CONTEXT_ROOT", "/mnt/ha-apps/mcp/context")
|
||||
os.environ.setdefault("MCP_VECTOR_FALLBACK", str(ROOT / "data" / "vector_fallback.db"))
|
||||
|
||||
|
||||
def _parse_day(s: Optional[str]) -> date:
|
||||
if not s:
|
||||
return date.today()
|
||||
return date.fromisoformat(s)
|
||||
|
||||
|
||||
def _day_bounds(day: date) -> tuple[datetime, datetime]:
|
||||
start = datetime.combine(day, datetime.min.time())
|
||||
end = start + timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
|
||||
def _is_noise_query(q: str) -> bool:
|
||||
q = (q or "").strip()
|
||||
if len(q) < 12:
|
||||
return True
|
||||
low = q.lower()
|
||||
if q.startswith("<") or q.startswith("[REDACTED]"):
|
||||
return True
|
||||
noise_prefixes = (
|
||||
"you are ",
|
||||
"you have access",
|
||||
"start multitasking",
|
||||
"briefly inform the user",
|
||||
"perform any necessary follow-up",
|
||||
"the following task has finished",
|
||||
"<mcp_",
|
||||
"<dynamic_tools>",
|
||||
"<agent_transcripts>",
|
||||
)
|
||||
return any(low.startswith(p) or p in low[:80] for p in noise_prefixes)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
text = re.sub(r"\s+", " ", (text or "").strip())
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _extract_text_blocks(message: Any) -> list[str]:
|
||||
if not isinstance(message, dict):
|
||||
return []
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return [content]
|
||||
out: list[str] = []
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
t = part.get("text") or ""
|
||||
if t and t != "[REDACTED]":
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_event_time(raw: str, file_mtime: float) -> Optional[datetime]:
|
||||
raw = (raw or "").strip()
|
||||
cleaned = re.sub(r"\s*\([^)]*\)\s*$", "", raw).strip()
|
||||
for fmt in (
|
||||
"%A, %b %d, %Y, %I:%M %p",
|
||||
"%A, %B %d, %Y, %I:%M %p",
|
||||
"%Y-%m-%dT%H:%M:%S%z",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
):
|
||||
try:
|
||||
return datetime.strptime(cleaned, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return datetime.fromtimestamp(file_mtime)
|
||||
|
||||
|
||||
def collect_transcripts(day: date, roots: list[Path], max_chats: int = 25) -> list[dict]:
|
||||
start, end = _day_bounds(day)
|
||||
chats: list[dict] = []
|
||||
files: list[Path] = []
|
||||
for root in roots:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for path in root.rglob("*.jsonl"):
|
||||
if "subagents" in path.parts:
|
||||
continue
|
||||
files.append(path)
|
||||
|
||||
for path in sorted(files, key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
mtime = path.stat().st_mtime
|
||||
# quick skip: file entirely older than day-1 or newer handled by content
|
||||
if datetime.fromtimestamp(mtime) < start - timedelta(days=2):
|
||||
continue
|
||||
user_queries: list[str] = []
|
||||
assistant_tails: list[str] = []
|
||||
paths_touched: Counter[str] = Counter()
|
||||
day_hit = False
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
role = obj.get("role")
|
||||
for text in _extract_text_blocks(obj.get("message") or {}):
|
||||
if role == "user":
|
||||
for ts_raw, query in USER_QUERY_RE.findall(text):
|
||||
when = _parse_event_time(ts_raw, mtime)
|
||||
if when and start <= when < end:
|
||||
day_hit = True
|
||||
q = _truncate(query, 280)
|
||||
if not _is_noise_query(q):
|
||||
user_queries.append(q)
|
||||
# bare user text without wrapper — solo se breve messaggio umano
|
||||
if (
|
||||
"<user_query>" not in text
|
||||
and start.timestamp() <= mtime < end.timestamp()
|
||||
and len(text) > 40
|
||||
and not _is_noise_query(text)
|
||||
and "<" not in text[:20]
|
||||
):
|
||||
day_hit = True
|
||||
user_queries.append(_truncate(text, 280))
|
||||
elif role == "assistant":
|
||||
if start.timestamp() <= mtime < end.timestamp() or day_hit:
|
||||
if (
|
||||
len(text) > 80
|
||||
and not text.startswith("[REDACTED]")
|
||||
and not text.startswith("<")
|
||||
and "tool_use" not in text[:40]
|
||||
):
|
||||
# preferisci paragrafi conclusivi (markdown grassetto / verdetto)
|
||||
assistant_tails.append(_truncate(text, 320))
|
||||
# tool paths
|
||||
msg = obj.get("message") or {}
|
||||
content = msg.get("content") if isinstance(msg, dict) else None
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if not isinstance(part, dict) or part.get("type") != "tool_use":
|
||||
continue
|
||||
inp = part.get("input") or {}
|
||||
for key in ("path", "target_notebook", "file_path"):
|
||||
if key in inp and isinstance(inp[key], str):
|
||||
paths_touched[inp[key]] += 1
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if not day_hit and not user_queries:
|
||||
# include if file modified that day and has substance
|
||||
if not (start.timestamp() <= mtime < end.timestamp()):
|
||||
continue
|
||||
if not assistant_tails:
|
||||
continue
|
||||
|
||||
# dedupe queries
|
||||
seen = set()
|
||||
uniq_q = []
|
||||
for q in user_queries:
|
||||
if q not in seen:
|
||||
seen.add(q)
|
||||
uniq_q.append(q)
|
||||
chats.append(
|
||||
{
|
||||
"id": path.parent.name if path.parent.name != "agent-transcripts" else path.stem,
|
||||
"queries": uniq_q[:8],
|
||||
"conclusions": assistant_tails[-3:],
|
||||
"paths": [p for p, _ in paths_touched.most_common(8)],
|
||||
}
|
||||
)
|
||||
if len(chats) >= max_chats:
|
||||
break
|
||||
return chats
|
||||
|
||||
|
||||
def collect_audit(day: date, db_path: Path) -> dict:
|
||||
if not db_path.is_file():
|
||||
return {"tools": [], "total": 0}
|
||||
start = day.isoformat()
|
||||
end = (day + timedelta(days=1)).isoformat()
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"SELECT tool_name, COUNT(*) AS n FROM audit_log"
|
||||
" WHERE created_at >= ? AND created_at < ? AND username=?"
|
||||
" GROUP BY tool_name ORDER BY n DESC",
|
||||
(start, end, "daniele"),
|
||||
).fetchall()
|
||||
samples = conn.execute(
|
||||
"SELECT created_at, tool_name, detail FROM audit_log"
|
||||
" WHERE created_at >= ? AND created_at < ? AND username=?"
|
||||
" ORDER BY id DESC LIMIT 15",
|
||||
(start, end, "daniele"),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return {
|
||||
"tools": [(r["tool_name"], r["n"]) for r in rows],
|
||||
"total": sum(r["n"] for r in rows),
|
||||
"samples": [
|
||||
{
|
||||
"at": r["created_at"],
|
||||
"tool": r["tool_name"],
|
||||
"detail": _truncate(r["detail"] or "", 120),
|
||||
}
|
||||
for r in samples
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def collect_git(day: date, repos: list[Path], limit: int = 12) -> list[str]:
|
||||
since = day.isoformat()
|
||||
until = (day + timedelta(days=1)).isoformat()
|
||||
lines: list[str] = []
|
||||
for repo in repos:
|
||||
if not (repo / ".git").exists():
|
||||
continue
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(repo),
|
||||
"log",
|
||||
f"--since={since}",
|
||||
f"--until={until}",
|
||||
"--pretty=format:%h %s",
|
||||
f"-n{limit}",
|
||||
],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=15,
|
||||
).strip()
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
continue
|
||||
if out:
|
||||
for line in out.splitlines():
|
||||
lines.append(f"{repo.name}: {line}")
|
||||
return lines[:limit]
|
||||
|
||||
|
||||
def _parse_plan_file(path: Path) -> dict:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
name = path.stem
|
||||
overview = ""
|
||||
todos_total = 0
|
||||
todos_done = 0
|
||||
m = FRONTMATTER_RE.match(text)
|
||||
if m:
|
||||
fm = m.group(1)
|
||||
nm = re.search(r"^name:\s*(.+)$", fm, re.M)
|
||||
if nm:
|
||||
name = nm.group(1).strip().strip("\"'")
|
||||
ov = re.search(r"^overview:\s*(.+)$", fm, re.M)
|
||||
if ov:
|
||||
overview = ov.group(1).strip().strip("\"'")
|
||||
# overview può essere su più righe YAML quoted — fallback grezzo
|
||||
if overview.startswith("|") or not overview:
|
||||
ov2 = re.search(r"^overview:\s*[>|]?\s*\n((?:[ \t]+.+\n)+)", fm, re.M)
|
||||
if ov2:
|
||||
overview = " ".join(line.strip() for line in ov2.group(1).splitlines())
|
||||
statuses = re.findall(r"^\s+status:\s*(\w+)", fm, re.M)
|
||||
todos_total = len(statuses)
|
||||
todos_done = sum(1 for s in statuses if s == "completed")
|
||||
mtime = datetime.fromtimestamp(path.stat().st_mtime)
|
||||
return {
|
||||
"file": path.name,
|
||||
"name": name,
|
||||
"overview": _truncate(overview, 220),
|
||||
"todos_total": todos_total,
|
||||
"todos_done": todos_done,
|
||||
"mtime": mtime,
|
||||
"text": text,
|
||||
"path": path,
|
||||
}
|
||||
|
||||
|
||||
def collect_and_sync_plans(
|
||||
day: date,
|
||||
plans_dir: Path,
|
||||
dest_dir: Path,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
"""Copia tutti i .plan.md in artifacts/plans/; ritorna catalogo + aggiornati nel giorno."""
|
||||
start, end = _day_bounds(day)
|
||||
plans: list[dict] = []
|
||||
if plans_dir.is_dir():
|
||||
for path in sorted(plans_dir.glob("*.plan.md")):
|
||||
try:
|
||||
plans.append(_parse_plan_file(path))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
copied = 0
|
||||
updated_today: list[dict] = []
|
||||
if not dry_run:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
for plan in plans:
|
||||
target = dest_dir / plan["file"]
|
||||
shutil.copy2(plan["path"], target)
|
||||
copied += 1
|
||||
if start <= plan["mtime"] < end:
|
||||
updated_today.append(plan)
|
||||
# INDEX.md per lettura umana / futuri tool
|
||||
idx_lines = [
|
||||
"# Piani Cursor (sync)",
|
||||
"",
|
||||
f"_Aggiornato {datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')} "
|
||||
f"da `~/.cursor/plans` → `artifacts/plans/`._",
|
||||
"",
|
||||
]
|
||||
for plan in sorted(plans, key=lambda p: p["mtime"], reverse=True):
|
||||
prog = (
|
||||
f"{plan['todos_done']}/{plan['todos_total']}"
|
||||
if plan["todos_total"]
|
||||
else "?"
|
||||
)
|
||||
idx_lines.append(
|
||||
f"- **{plan['name']}** (`{plan['file']}`, todos {prog}, "
|
||||
f"mtime {plan['mtime'].date().isoformat()})"
|
||||
)
|
||||
if plan["overview"]:
|
||||
idx_lines.append(f" - {plan['overview']}")
|
||||
idx_lines.append("")
|
||||
(dest_dir / "INDEX.md").write_text("\n".join(idx_lines), encoding="utf-8")
|
||||
else:
|
||||
updated_today = [p for p in plans if start <= p["mtime"] < end]
|
||||
|
||||
return {
|
||||
"plans": plans,
|
||||
"copied": copied,
|
||||
"updated_today": updated_today,
|
||||
"dest": str(dest_dir),
|
||||
}
|
||||
|
||||
|
||||
def upsert_plans_index_section(context_md: str, plans: list[dict]) -> str:
|
||||
"""Sezione stabile in context.md (non nel digest giornaliero) con indice piani."""
|
||||
begin = "<!-- BEGIN CURSOR PLANS -->"
|
||||
end = "<!-- END CURSOR PLANS -->"
|
||||
lines = [
|
||||
begin,
|
||||
"## Piani Cursor (indice sync)",
|
||||
"",
|
||||
"_File completi in `artifacts/plans/`. Qui solo indice per `get_project_context`._",
|
||||
"",
|
||||
]
|
||||
if not plans:
|
||||
lines.append("- Nessun piano in `~/.cursor/plans`.")
|
||||
else:
|
||||
for plan in sorted(plans, key=lambda p: p["mtime"], reverse=True):
|
||||
prog = (
|
||||
f"{plan['todos_done']}/{plan['todos_total']} done"
|
||||
if plan["todos_total"]
|
||||
else "n/d"
|
||||
)
|
||||
lines.append(
|
||||
f"- **{plan['name']}** — {prog} — `{plan['file']}` "
|
||||
f"({plan['mtime'].date().isoformat()})"
|
||||
)
|
||||
if plan["overview"]:
|
||||
lines.append(f" - {plan['overview']}")
|
||||
lines.extend(["", end, ""])
|
||||
block = "\n".join(lines)
|
||||
|
||||
if begin in context_md and end in context_md:
|
||||
pattern = re.compile(
|
||||
re.escape(begin) + r".*?" + re.escape(end),
|
||||
re.DOTALL,
|
||||
)
|
||||
return pattern.sub(block.strip(), context_md)
|
||||
# inserisci prima dei daily digests se presenti
|
||||
dig = "<!-- BEGIN DAILY DIGESTS -->"
|
||||
if dig in context_md:
|
||||
head, tail = context_md.split(dig, 1)
|
||||
return head.rstrip() + "\n\n" + block + "\n" + dig + tail
|
||||
return context_md.rstrip() + "\n\n" + block
|
||||
|
||||
|
||||
def build_markdown(
|
||||
day: date,
|
||||
chats: list[dict],
|
||||
audit: dict,
|
||||
git_lines: list[str],
|
||||
project_id: str,
|
||||
plans_updated: Optional[list[dict]] = None,
|
||||
) -> str:
|
||||
lines: list[str] = [
|
||||
f"<!-- daily-digest:{day.isoformat()} -->",
|
||||
f"## Digest agenti {day.isoformat()} — `{project_id}`",
|
||||
"",
|
||||
f"_Generato automaticamente da `export_daily_agent_digest.py` "
|
||||
f"({datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')})._",
|
||||
"",
|
||||
]
|
||||
|
||||
lines.append("### Chat / agenti Cursor")
|
||||
if not chats:
|
||||
lines.append("- Nessun transcript rilevante per questo giorno.")
|
||||
else:
|
||||
lines.append(f"- Sessioni considerate: **{len(chats)}**")
|
||||
for i, chat in enumerate(chats, 1):
|
||||
title = chat["queries"][0] if chat["queries"] else chat["id"]
|
||||
lines.append(f"{i}. **{_truncate(title, 120)}** `[{chat['id'][:8]}]`")
|
||||
for q in chat["queries"][1:4]:
|
||||
lines.append(f" - Q: {_truncate(q, 160)}")
|
||||
for c in chat["conclusions"][-2:]:
|
||||
lines.append(f" - → {_truncate(c, 200)}")
|
||||
if chat["paths"]:
|
||||
short_paths = ", ".join(_truncate(p, 60) for p in chat["paths"][:5])
|
||||
lines.append(f" - File: `{short_paths}`")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Tool MCP usati")
|
||||
if not audit.get("total"):
|
||||
lines.append("- Nessuna chiamata tool in audit_log.")
|
||||
else:
|
||||
lines.append(f"- Totale chiamate: **{audit['total']}**")
|
||||
top = ", ".join(f"`{name}`×{n}" for name, n in audit["tools"][:10])
|
||||
lines.append(f"- Top: {top}")
|
||||
for s in (audit.get("samples") or [])[:8]:
|
||||
lines.append(f" - {s['at']} `{s['tool']}` {s['detail']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Commit git (homelab)")
|
||||
if not git_lines:
|
||||
lines.append("- Nessun commit nel giorno.")
|
||||
else:
|
||||
for g in git_lines:
|
||||
lines.append(f"- `{g}`")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Piani Cursor aggiornati oggi")
|
||||
plans_updated = plans_updated or []
|
||||
if not plans_updated:
|
||||
lines.append("- Nessun `.plan.md` modificato in questa data.")
|
||||
else:
|
||||
for plan in plans_updated:
|
||||
prog = (
|
||||
f"{plan['todos_done']}/{plan['todos_total']}"
|
||||
if plan["todos_total"]
|
||||
else "?"
|
||||
)
|
||||
lines.append(
|
||||
f"- **{plan['name']}** (`artifacts/plans/{plan['file']}`, todos {prog})"
|
||||
)
|
||||
if plan["overview"]:
|
||||
lines.append(f" - {plan['overview']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Per Claude / prossimi agenti")
|
||||
lines.append(
|
||||
"- Usa questo blocco come memoria del giorno; per dettagli codice preferisci "
|
||||
"`search_gitea_knowledge` / `get_file` sui path citati."
|
||||
)
|
||||
lines.append(
|
||||
"- Piani completi: sezione **Piani Cursor** in context.md + file in `artifacts/plans/`."
|
||||
)
|
||||
lines.append(
|
||||
"- Non ripetere setup già conclusi; riparti da decisioni e next step qui sopra."
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def upsert_daily_block(context_md: str, day: date, block: str) -> str:
|
||||
"""Rimuove eventuale blocco del giorno e inserisce il nuovo in cima alla sezione digest."""
|
||||
pattern = re.compile(
|
||||
rf"<!--\s*daily-digest:{day.isoformat()}\s*-->.*?"
|
||||
rf"(?=<!--\s*daily-digest:\d{{4}}-\d{{2}}-\d{{2}}\s*-->|<!--\s*END DAILY DIGESTS\s*-->|\Z)",
|
||||
re.DOTALL,
|
||||
)
|
||||
context_md = pattern.sub("", context_md)
|
||||
|
||||
begin = "<!-- BEGIN DAILY DIGESTS -->"
|
||||
end = "<!-- END DAILY DIGESTS -->"
|
||||
if begin not in context_md:
|
||||
context_md = context_md.rstrip() + f"\n\n{begin}\n\n{end}\n"
|
||||
|
||||
# Assicura END
|
||||
if end not in context_md:
|
||||
context_md = context_md.rstrip() + f"\n\n{end}\n"
|
||||
|
||||
head, rest = context_md.split(begin, 1)
|
||||
# rest inizia dopo BEGIN; togli END temporaneamente dalla porzione digest
|
||||
if end in rest:
|
||||
mid, tail = rest.split(end, 1)
|
||||
else:
|
||||
mid, tail = rest, ""
|
||||
mid = mid.strip()
|
||||
new_mid = block.strip() + ("\n\n" + mid if mid else "")
|
||||
return head.rstrip() + f"\n\n{begin}\n\n{new_mid}\n\n{end}" + tail
|
||||
|
||||
|
||||
def has_daily_block(context_md: str, day: date) -> bool:
|
||||
return f"<!-- daily-digest:{day.isoformat()} -->" in context_md
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Digest giornaliero agenti → MCP context")
|
||||
parser.add_argument("--date", help="YYYY-MM-DD (default: oggi)")
|
||||
parser.add_argument("--user", default="daniele")
|
||||
parser.add_argument("--project", default="homelab-loogle")
|
||||
parser.add_argument("--force", action="store_true", help="Sostituisci blocco del giorno se già presente")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--no-index", action="store_true", help="Non aggiornare Qdrant ctx_*")
|
||||
parser.add_argument("--max-chats", type=int, default=20)
|
||||
parser.add_argument(
|
||||
"--no-plans",
|
||||
action="store_true",
|
||||
help="Non sincronizzare ~/.cursor/plans",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
_load_dotenv()
|
||||
_configure_paths()
|
||||
day = _parse_day(args.date)
|
||||
|
||||
proj = Path(os.environ["MCP_CONTEXT_ROOT"]) / args.user / "projects" / args.project
|
||||
plans_dest = proj / "artifacts" / "plans"
|
||||
|
||||
plans_info: dict = {"plans": [], "copied": 0, "updated_today": [], "dest": str(plans_dest)}
|
||||
if not args.no_plans:
|
||||
plans_info = collect_and_sync_plans(
|
||||
day,
|
||||
DEFAULT_PLANS_DIR,
|
||||
plans_dest,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
chats = collect_transcripts(day, DEFAULT_TRANSCRIPT_ROOTS, max_chats=args.max_chats)
|
||||
audit = collect_audit(day, Path(os.environ["MCP_DB"]))
|
||||
git_lines = collect_git(day, DEFAULT_GIT_REPOS)
|
||||
block = build_markdown(
|
||||
day,
|
||||
chats,
|
||||
audit,
|
||||
git_lines,
|
||||
args.project,
|
||||
plans_updated=plans_info.get("updated_today") or [],
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
print(block)
|
||||
if plans_info.get("plans"):
|
||||
print("\n# plans index preview", file=sys.stderr)
|
||||
for p in plans_info["plans"][:5]:
|
||||
print(f"# - {p['name']} ({p['file']})", file=sys.stderr)
|
||||
print(
|
||||
f"\n# dry-run chats={len(chats)} audit={audit.get('total', 0)} "
|
||||
f"git={len(git_lines)} plans={len(plans_info.get('plans') or [])} "
|
||||
f"plans_today={len(plans_info.get('updated_today') or [])} chars={len(block)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
from app.context import store as context_store
|
||||
from app.db import init_db
|
||||
from app.knowledge import indexer
|
||||
|
||||
init_db()
|
||||
data = context_store.get_project_context(args.user, args.project, session_limit=1, include_gitea=False)
|
||||
existing = data.get("context_md") or ""
|
||||
|
||||
# I piani si sincronizzano sempre; il digest può essere skippato se già presente
|
||||
existing = upsert_plans_index_section(existing, plans_info.get("plans") or [])
|
||||
|
||||
digest_skipped = False
|
||||
if has_daily_block(existing, day) and not args.force:
|
||||
digest_skipped = True
|
||||
new_md = existing
|
||||
print(f"SKIP digest: {day.isoformat()} già presente (usa --force); plans sync ok")
|
||||
else:
|
||||
new_md = upsert_daily_block(existing, day, block)
|
||||
|
||||
ctx_path = proj / "context.md"
|
||||
meta_path = proj / "meta.json"
|
||||
sessions = proj / "sessions"
|
||||
sessions.mkdir(parents=True, exist_ok=True)
|
||||
ctx_path.write_text(new_md, encoding="utf-8")
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
meta["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
snap = None
|
||||
if not digest_skipped:
|
||||
snap = sessions / f"{day.isoformat()}-daily.md"
|
||||
snap.write_text(block, encoding="utf-8")
|
||||
|
||||
if not args.no_index:
|
||||
try:
|
||||
if not digest_skipped:
|
||||
indexer.index_context_snippet(
|
||||
args.user,
|
||||
args.project,
|
||||
block,
|
||||
f"Digest {day.isoformat()} — {meta.get('title', args.project)}",
|
||||
snippet_id=f"daily-{day.isoformat()}",
|
||||
)
|
||||
# indicizza ogni piano (testo ridotto: name+overview+body troncato)
|
||||
for plan in plans_info.get("plans") or []:
|
||||
body = plan["text"]
|
||||
if len(body) > 12000:
|
||||
body = body[:12000] + "\n…[troncato]"
|
||||
indexer.index_context_snippet(
|
||||
args.user,
|
||||
args.project,
|
||||
f"# Piano Cursor: {plan['name']}\n\n{plan['overview']}\n\n{body}",
|
||||
f"Plan: {plan['name']}",
|
||||
snippet_id=f"plan-{plan['file']}",
|
||||
)
|
||||
print("indexed: ok")
|
||||
except Exception as exc:
|
||||
print(f"indexed: skip ({exc})")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"day": day.isoformat(),
|
||||
"project": args.project,
|
||||
"digest_skipped": digest_skipped,
|
||||
"chats": len(chats),
|
||||
"audit_calls": audit.get("total", 0),
|
||||
"git_commits": len(git_lines),
|
||||
"plans_synced": plans_info.get("copied", 0),
|
||||
"plans_updated_today": len(plans_info.get("updated_today") or []),
|
||||
"chars": len(block),
|
||||
"context_chars": len(new_md),
|
||||
"session": str(snap) if snap else None,
|
||||
"plans_dest": plans_info.get("dest"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Grant view permissions on non-personal Paperless documents to family users."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
PAPERLESS_URL = os.environ.get("PAPERLESS_URL", "https://docs.loogle.it").rstrip("/")
|
||||
ADMIN_TOKEN = os.environ.get("PAPERLESS_API_TOKEN_DANIELE") or os.environ.get("PAPERLESS_API_TOKEN", "")
|
||||
FAMILY_USER_IDS = [4, 5, 6] # lucia, davide, luca
|
||||
PERSONAL_TAG_NAMES = {"personal", "privato", "private"}
|
||||
BATCH_SIZE = 50
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not ADMIN_TOKEN:
|
||||
print("Missing PAPERLESS_API_TOKEN_DANIELE", file=sys.stderr)
|
||||
return 1
|
||||
headers = {"Authorization": f"Token {ADMIN_TOKEN}", "Content-Type": "application/json"}
|
||||
|
||||
tags_resp = httpx.get(f"{PAPERLESS_URL}/api/tags/", headers=headers, timeout=60)
|
||||
tags_resp.raise_for_status()
|
||||
personal_tag_ids = {
|
||||
t["id"]
|
||||
for t in tags_resp.json().get("results", [])
|
||||
if (t.get("name") or "").lower() in PERSONAL_TAG_NAMES
|
||||
}
|
||||
|
||||
doc_ids: list[int] = []
|
||||
page = 1
|
||||
while True:
|
||||
resp = httpx.get(
|
||||
f"{PAPERLESS_URL}/api/documents/",
|
||||
headers=headers,
|
||||
params={"page": page, "page_size": 100},
|
||||
timeout=60,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
for doc in data.get("results", []):
|
||||
if set(doc.get("tags") or []) & personal_tag_ids:
|
||||
continue
|
||||
doc_ids.append(doc["id"])
|
||||
if not data.get("next"):
|
||||
break
|
||||
page += 1
|
||||
|
||||
print(f"Documenti da condividere (senza tag personali): {len(doc_ids)}")
|
||||
|
||||
updated = 0
|
||||
for i in range(0, len(doc_ids), BATCH_SIZE):
|
||||
chunk = doc_ids[i : i + BATCH_SIZE]
|
||||
payload = {
|
||||
"documents": chunk,
|
||||
"method": "set_permissions",
|
||||
"parameters": {
|
||||
"set_permissions": {
|
||||
"view": {"users": FAMILY_USER_IDS, "groups": []},
|
||||
"change": {"users": [], "groups": []},
|
||||
},
|
||||
"merge": True,
|
||||
},
|
||||
}
|
||||
resp = httpx.post(
|
||||
f"{PAPERLESS_URL}/api/documents/bulk_edit/",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
updated += len(chunk)
|
||||
print(f" Aggiornati {updated}/{len(doc_ids)}")
|
||||
|
||||
for name, env_key in [
|
||||
("lucia", "PAPERLESS_API_TOKEN_LUCIA"),
|
||||
("davide", "PAPERLESS_API_TOKEN_DAVIDE"),
|
||||
("luca", "PAPERLESS_API_TOKEN_LUCA"),
|
||||
]:
|
||||
token = os.environ.get(env_key, "")
|
||||
if not token:
|
||||
continue
|
||||
count = httpx.get(
|
||||
f"{PAPERLESS_URL}/api/documents/?page_size=1",
|
||||
headers={"Authorization": f"Token {token}"},
|
||||
timeout=30,
|
||||
).json().get("count", 0)
|
||||
print(f"{name}: documenti visibili = {count}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Indicizzazione Gitea one-shot — eseguire con indexer fermo per evitare OOM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
env_path = os.path.join(ROOT, ".env")
|
||||
if not os.path.isfile(env_path):
|
||||
return
|
||||
with open(env_path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
if key.strip() and key.strip() not in os.environ:
|
||||
os.environ[key.strip()] = value.strip().strip("'").strip('"')
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Indicizza repo Gitea nel vector store")
|
||||
parser.add_argument("--repo", help="owner/name (default: tutti i repo configurati)")
|
||||
parser.add_argument("--user", default="daniele", help="Utente MCP/Gitea token")
|
||||
parser.add_argument("--max-files", type=int, default=0, help="Limite file per repo (0=config)")
|
||||
parser.add_argument("--force", action="store_true", help="Re-indicizza anche file invariati")
|
||||
args = parser.parse_args()
|
||||
|
||||
_load_dotenv()
|
||||
if os.path.isfile("/.dockerenv"):
|
||||
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
|
||||
os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
|
||||
|
||||
from app.db import init_db
|
||||
from app.knowledge import gitea_indexer
|
||||
|
||||
init_db()
|
||||
max_files = args.max_files or None
|
||||
|
||||
if args.repo:
|
||||
result = gitea_indexer.index_repo(
|
||||
args.repo,
|
||||
username=args.user,
|
||||
force=args.force,
|
||||
max_files=max_files,
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
result = gitea_indexer.index_all(max_files_per_repo=max_files)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
stats = gitea_indexer.index_stats()
|
||||
print("stats", json.dumps(stats, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Crea un progetto demo per ogni utente famiglia."""
|
||||
import sys
|
||||
sys.path.insert(0, "/srv")
|
||||
from app.context import store as context_store
|
||||
|
||||
USERS = {
|
||||
"daniele": "Homelab LOOGLE",
|
||||
"lucia": "Progetti personali",
|
||||
"davide": "Studio e task",
|
||||
"luca": "Progetti Luca",
|
||||
}
|
||||
|
||||
for username, title in USERS.items():
|
||||
root = context_store._user_root(username)
|
||||
existing = context_store.list_projects(username)
|
||||
if existing:
|
||||
print(f"{username}: skip ({len(existing)} progetti)")
|
||||
continue
|
||||
meta = context_store.create_project(username, title, tags=["demo"])
|
||||
context_store.save_context(
|
||||
username,
|
||||
meta["id"],
|
||||
f"Progetto demo iniziale per {username}. Usa save_context per ampliare la memoria agente.",
|
||||
)
|
||||
print(f"{username}: creato {meta['id']}")
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Crea repo progetti per davide/luca e progetti Loogle collegati."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
path = os.path.join(ROOT, ".env")
|
||||
if not os.path.isfile(path):
|
||||
return
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, _, v = line.partition("=")
|
||||
os.environ[k.strip()] = v.strip().strip("'").strip('"')
|
||||
|
||||
|
||||
def ensure_repo(user: str, repo_name: str, description: str) -> str:
|
||||
from app.knowledge import gitea
|
||||
|
||||
gitea._tokens_cache = None
|
||||
full = f"{user}/{repo_name}"
|
||||
existing = {r["full_name"] for r in gitea.list_repos(username=user)["repos"]}
|
||||
if full in existing:
|
||||
print(f"repo_exists: {full}")
|
||||
return full
|
||||
created = gitea.create_repo(
|
||||
repo_name,
|
||||
username=user,
|
||||
private=True,
|
||||
description=description,
|
||||
auto_init=True,
|
||||
)
|
||||
print(f"repo_created: {created['full_name']}")
|
||||
return created["full_name"]
|
||||
|
||||
|
||||
def ensure_mcp_project(user: str, project_title: str, gitea_repo: str) -> None:
|
||||
from app.context import store as context_store
|
||||
|
||||
projects = {p["id"]: p for p in context_store.list_projects(user, include_archived=True)}
|
||||
for meta in projects.values():
|
||||
if meta.get("gitea_repo") == gitea_repo:
|
||||
print(f"project_linked: {user}/{meta['id']} -> {gitea_repo}")
|
||||
return
|
||||
slug = project_title.lower().replace(" ", "-")
|
||||
if slug in projects:
|
||||
meta = context_store.link_project_repo(user, slug, gitea_repo=gitea_repo, seed_from_gitea=True)
|
||||
print(f"project_updated: {user}/{meta['id']} -> {gitea_repo}")
|
||||
return
|
||||
meta = context_store.create_project(
|
||||
user,
|
||||
project_title,
|
||||
tags=["gitea", "p4"],
|
||||
gitea_repo=gitea_repo,
|
||||
seed_from_gitea=True,
|
||||
)
|
||||
print(f"project_created: {user}/{meta['id']} -> {gitea_repo}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_load_dotenv()
|
||||
os.environ.setdefault("MCP_DB", os.environ.get("MCP_DB", "/data/loogle_mcp.db"))
|
||||
from app.db import init_db
|
||||
|
||||
init_db()
|
||||
|
||||
ensure_repo(
|
||||
"davide",
|
||||
"progetti",
|
||||
"Workspace personale — codice e note generate con Loogle MCP",
|
||||
)
|
||||
ensure_repo(
|
||||
"luca",
|
||||
"progetti",
|
||||
"Workspace personale — codice e note generate con Loogle MCP",
|
||||
)
|
||||
|
||||
ensure_mcp_project("davide", "Progetti Davide", "davide/progetti")
|
||||
ensure_mcp_project("luca", "Progetti Luca", "luca/progetti")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,258 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test integrazione Gitea MCP — mock server + opzionale live API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
MOCK_TOKEN = "test-gitea-token"
|
||||
MOCK_PORT = 18799
|
||||
|
||||
os.environ["GITEA_URL"] = "https://git.loogle.it"
|
||||
os.environ["GITEA_API_URL"] = f"http://127.0.0.1:{MOCK_PORT}"
|
||||
os.environ["GITEA_API_TOKEN_DANIELE"] = MOCK_TOKEN
|
||||
os.environ["MCP_DB"] = "/tmp/loogle_mcp_test.db"
|
||||
|
||||
|
||||
class GiteaMockHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
|
||||
def _auth_ok(self) -> bool:
|
||||
auth = self.headers.get("Authorization", "")
|
||||
return auth == f"token {MOCK_TOKEN}"
|
||||
|
||||
def _json(self, code: int, payload):
|
||||
body = json.dumps(payload).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if not self._auth_ok():
|
||||
self._json(401, {"message": "invalid token"})
|
||||
return
|
||||
path = urlparse(self.path).path
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
|
||||
if path == "/api/v1/user/repos":
|
||||
self._json(200, [
|
||||
{
|
||||
"full_name": "daniele/rete",
|
||||
"description": "Infra HA",
|
||||
"private": True,
|
||||
"html_url": "https://git.loogle.it/daniele/rete",
|
||||
"default_branch": "main",
|
||||
"updated_at": "2026-08-22T10:00:00Z",
|
||||
}
|
||||
])
|
||||
return
|
||||
|
||||
if path == "/api/v1/repos/daniele/rete/contents/ha/RUNBOOK-failover.md":
|
||||
content = "# Failover tier-b\n\nloogle-mcp nel pivot tier-b.\n"
|
||||
import base64
|
||||
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"type": "file",
|
||||
"path": "ha/RUNBOOK-failover.md",
|
||||
"content": base64.b64encode(content.encode()).decode(),
|
||||
"encoding": "base64",
|
||||
"size": len(content),
|
||||
"sha": "abc123",
|
||||
"html_url": "https://git.loogle.it/daniele/rete/src/branch/main/ha/RUNBOOK-failover.md",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if path == "/api/v1/search/code":
|
||||
q = (qs.get("q") or [""])[0]
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"repository": {"full_name": "daniele/rete"},
|
||||
"path": "ha/RUNBOOK-failover.md",
|
||||
"sha": "abc123",
|
||||
"content": f"...{q}...",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if path == "/api/v1/repos/daniele/rete/issues":
|
||||
self._json(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Test issue",
|
||||
"state": "open",
|
||||
"user": {"login": "daniele"},
|
||||
"html_url": "https://git.loogle.it/daniele/rete/issues/1",
|
||||
"created_at": "2026-08-22T10:00:00Z",
|
||||
"updated_at": "2026-08-22T10:00:00Z",
|
||||
"labels": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
return
|
||||
|
||||
if path == "/api/v1/repos/daniele/rete/issues/1":
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Test issue",
|
||||
"state": "open",
|
||||
"body": "Corpo issue di test",
|
||||
"user": {"login": "daniele"},
|
||||
"html_url": "https://git.loogle.it/daniele/rete/issues/1",
|
||||
"created_at": "2026-08-22T10:00:00Z",
|
||||
"updated_at": "2026-08-22T10:00:00Z",
|
||||
"labels": [],
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
self._json(404, {"message": f"not found: {path}"})
|
||||
|
||||
def do_POST(self):
|
||||
if not self._auth_ok():
|
||||
self._json(401, {"message": "invalid token"})
|
||||
return
|
||||
path = urlparse(self.path).path
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length).decode() or "{}")
|
||||
if path == "/api/v1/repos/daniele/rete/issues":
|
||||
self._json(
|
||||
201,
|
||||
{
|
||||
"number": 42,
|
||||
"title": body.get("title"),
|
||||
"state": "open",
|
||||
"html_url": "https://git.loogle.it/daniele/rete/issues/42",
|
||||
},
|
||||
)
|
||||
return
|
||||
self._json(404, {"message": f"not found: {path}"})
|
||||
|
||||
|
||||
def run_mock_tests() -> None:
|
||||
from app.db import init_db
|
||||
from app.knowledge import gitea as gitea_mod
|
||||
from app.mcp import tools
|
||||
|
||||
init_db()
|
||||
gitea_mod._tokens_cache = None
|
||||
|
||||
server = HTTPServer(("127.0.0.1", MOCK_PORT), GiteaMockHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
claims = {"sub": "daniele", "scope": "gitea:read gitea:write"}
|
||||
|
||||
repos = tools.call_tool("list_repos", {}, claims)
|
||||
assert "daniele/rete" in repos["content"][0]["text"]
|
||||
|
||||
file_data = tools.call_tool(
|
||||
"get_file",
|
||||
{"repo": "daniele/rete", "path": "ha/RUNBOOK-failover.md"},
|
||||
claims,
|
||||
)
|
||||
assert "tier-b" in file_data["content"][0]["text"]
|
||||
|
||||
search = tools.call_tool(
|
||||
"search_code",
|
||||
{"query": "tier-b", "repo": "daniele/rete"},
|
||||
claims,
|
||||
)
|
||||
assert "RUNBOOK-failover" in search["content"][0]["text"]
|
||||
|
||||
issues = tools.call_tool("list_issues", {"repo": "daniele/rete"}, claims)
|
||||
assert "Test issue" in issues["content"][0]["text"]
|
||||
|
||||
issue = tools.call_tool(
|
||||
"get_issue",
|
||||
{"repo": "daniele/rete", "number": 1},
|
||||
claims,
|
||||
)
|
||||
assert "Corpo issue" in issue["content"][0]["text"]
|
||||
|
||||
created = tools.call_tool(
|
||||
"create_issue",
|
||||
{"repo": "daniele/rete", "title": "Da MCP", "body": "Test"},
|
||||
claims,
|
||||
)
|
||||
assert "42" in created["content"][0]["text"]
|
||||
|
||||
server.shutdown()
|
||||
print("OK mock: list_repos, get_file, search_code, list_issues, get_issue, create_issue")
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
env_path = os.path.join(ROOT, ".env")
|
||||
if not os.path.isfile(env_path):
|
||||
return
|
||||
with open(env_path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip("'").strip('"')
|
||||
if key.startswith("GITEA_"):
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def run_live_tests() -> None:
|
||||
_load_dotenv()
|
||||
from app.knowledge import gitea as gitea_mod
|
||||
from app.mcp import tools
|
||||
|
||||
token = os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip()
|
||||
if not token or token == MOCK_TOKEN:
|
||||
print("SKIP live: GITEA_API_TOKEN_DANIELE non impostato (token reale in .env)")
|
||||
return
|
||||
|
||||
api_url = gitea_mod.api_base_url()
|
||||
if str(MOCK_PORT) in api_url:
|
||||
print("SKIP live: GITEA_API_URL punta al mock")
|
||||
return
|
||||
|
||||
gitea_mod._tokens_cache = None
|
||||
claims = {"sub": "daniele", "scope": "gitea:read gitea:write"}
|
||||
|
||||
repos = gitea_mod.list_repos(username="daniele")
|
||||
if not repos.get("repos"):
|
||||
raise RuntimeError("live list_repos: nessun repository")
|
||||
|
||||
first = repos["repos"][0]["full_name"]
|
||||
tools.call_tool("list_repos", {}, claims)
|
||||
print(f"OK live list_repos: {len(repos['repos'])} repo, primo={first}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
run_mock_tests()
|
||||
run_live_tests()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test P4: scrittura Gitea (create repo, create/update file)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
path = os.path.join(ROOT, ".env")
|
||||
if not os.path.isfile(path):
|
||||
return
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, _, v = line.partition("=")
|
||||
os.environ[k.strip()] = v.strip().strip("'").strip('"')
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_load_dotenv()
|
||||
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
|
||||
|
||||
from app.db import init_db
|
||||
from app.knowledge import gitea
|
||||
from app.mcp import tools
|
||||
|
||||
init_db()
|
||||
gitea._tokens_cache = None
|
||||
|
||||
if not os.environ.get("GITEA_API_TOKEN_DANIELE"):
|
||||
print("ERR: token daniele assente")
|
||||
return 1
|
||||
|
||||
claims = {"sub": "daniele", "scope": "gitea:read gitea:write knowledge:read admin"}
|
||||
|
||||
test_repo = "daniele/mcp-p4-test"
|
||||
test_path = "docs/p4-verify.md"
|
||||
test_content = "# P4 verify\n\nFile creato da test_gitea_p4.py\n"
|
||||
|
||||
# cleanup file if exists from prior run
|
||||
try:
|
||||
gitea.get_file(test_repo, test_path, username="daniele")
|
||||
gitea.create_or_update_file(
|
||||
test_repo,
|
||||
test_path,
|
||||
test_content + "\n(updated)\n",
|
||||
"test p4 update",
|
||||
username="daniele",
|
||||
)
|
||||
action = "update"
|
||||
except FileNotFoundError:
|
||||
# ensure repo exists - use temp repo name under daniele
|
||||
repos = {r["full_name"] for r in gitea.list_repos(username="daniele")["repos"]}
|
||||
if test_repo not in repos:
|
||||
created = tools.call_tool(
|
||||
"create_gitea_repo",
|
||||
{
|
||||
"name": "mcp-p4-test",
|
||||
"private": True,
|
||||
"description": "Repo temporaneo test P4",
|
||||
},
|
||||
claims,
|
||||
)
|
||||
print("create_repo", created["content"][0]["text"][:200])
|
||||
written = tools.call_tool(
|
||||
"create_or_update_file",
|
||||
{
|
||||
"repo": test_repo,
|
||||
"path": test_path,
|
||||
"content": test_content,
|
||||
"message": "test p4 create",
|
||||
"reindex": False,
|
||||
},
|
||||
claims,
|
||||
)
|
||||
payload = json.loads(written["content"][0]["text"])
|
||||
action = payload.get("action")
|
||||
print("write", action, payload.get("path"))
|
||||
|
||||
read = gitea.get_file(test_repo, test_path, username="daniele")
|
||||
assert "P4 verify" in read.get("content", ""), read
|
||||
print("read_ok", read["path"])
|
||||
|
||||
for user, repo in (("davide", "davide/progetti"), ("luca", "luca/progetti")):
|
||||
token_key = f"GITEA_API_TOKEN_{user.upper()}"
|
||||
if not os.environ.get(token_key):
|
||||
print(f"SKIP {user}: no token")
|
||||
continue
|
||||
gitea._tokens_cache = None
|
||||
listed = gitea.list_repos(username=user)
|
||||
names = {r["full_name"] for r in listed["repos"]}
|
||||
if repo not in names:
|
||||
raise RuntimeError(f"repo mancante per {user}: {repo}")
|
||||
print(f"{user}_repo_ok", repo)
|
||||
|
||||
print("OK P4: create_or_update_file + repos davide/luca/progetti")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,130 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test P3: RAG semantico su file Gitea indicizzati."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
env_path = os.path.join(ROOT, ".env")
|
||||
if not os.path.isfile(env_path):
|
||||
return
|
||||
with open(env_path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value.strip().strip("'").strip('"')
|
||||
|
||||
|
||||
def _configure_paths() -> str:
|
||||
in_container = os.path.isfile("/.dockerenv") or (
|
||||
os.path.isdir("/data") and os.access("/data", os.W_OK)
|
||||
)
|
||||
if in_container:
|
||||
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
|
||||
os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
|
||||
return "container"
|
||||
os.environ["MCP_DB"] = "/tmp/loogle_mcp_p3_test.db"
|
||||
os.environ["MCP_VECTOR_FALLBACK"] = "/tmp/loogle_mcp_p3_vectors.db"
|
||||
return "host"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_load_dotenv()
|
||||
mode = _configure_paths()
|
||||
print(f"mode={mode} db={os.environ['MCP_DB']} vector={os.environ['MCP_VECTOR_FALLBACK']}")
|
||||
|
||||
from app.db import get_conn, init_db
|
||||
from app.knowledge import gitea_indexer
|
||||
from app.mcp import tools
|
||||
|
||||
init_db()
|
||||
if not get_conn().execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'"
|
||||
).fetchone():
|
||||
raise RuntimeError("tabella indexed_gitea_files assente")
|
||||
|
||||
if not os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip():
|
||||
print("SKIP: GITEA_API_TOKEN_DANIELE assente")
|
||||
return 1
|
||||
|
||||
repo = "daniele/rete"
|
||||
existing = gitea_indexer.list_indexed_files(limit=5, repo=repo)
|
||||
stats = gitea_indexer.index_stats()
|
||||
if len(existing) < 1 or stats.get("qdrant_points", 0) < 1:
|
||||
index_result = gitea_indexer.index_repo(
|
||||
repo,
|
||||
username="daniele",
|
||||
force=True,
|
||||
max_files=1,
|
||||
)
|
||||
print("index", json.dumps(index_result, ensure_ascii=False))
|
||||
else:
|
||||
print("index_skip", "already", len(existing), "files")
|
||||
|
||||
files = gitea_indexer.list_indexed_files(limit=5, repo=repo)
|
||||
if not files:
|
||||
raise RuntimeError("indexed_gitea_files vuota")
|
||||
print("indexed_sample", [f["path"] for f in files[:3]])
|
||||
|
||||
claims = {"sub": "daniele", "scope": "knowledge:read gitea:read admin", "admin": "admin"}
|
||||
for query in ("failover tier-b", "runbook"):
|
||||
hits = gitea_indexer.search_gitea_knowledge("daniele", query, limit=5, is_admin=True)
|
||||
if not hits:
|
||||
raise RuntimeError(f"search_gitea_knowledge senza risultati per: {query}")
|
||||
top = hits[0]
|
||||
print(
|
||||
f"search_ok[{query}]",
|
||||
top.get("path") or top.get("title"),
|
||||
round(float(top.get("score", 0)), 3),
|
||||
)
|
||||
|
||||
tool_out = tools.call_tool(
|
||||
"search_gitea_knowledge",
|
||||
{"query": "failover", "limit": 5},
|
||||
claims,
|
||||
)
|
||||
if not json.loads(tool_out["content"][0]["text"]).get("results"):
|
||||
raise RuntimeError("tool search_gitea_knowledge vuoto")
|
||||
|
||||
unified = tools.call_tool(
|
||||
"search_knowledge",
|
||||
{"query": "FAILOVER CENSIMENTO documenti servizi", "limit": 12},
|
||||
claims,
|
||||
)
|
||||
unified_payload = json.loads(unified["content"][0]["text"])
|
||||
results = unified_payload.get("results") or []
|
||||
sources = set()
|
||||
for r in results:
|
||||
if r.get("repo"):
|
||||
sources.add("gitea")
|
||||
elif r.get("source"):
|
||||
sources.add(r["source"])
|
||||
else:
|
||||
sources.add("paperless")
|
||||
print("search_knowledge_sources", sorted(sources))
|
||||
if "gitea" not in sources:
|
||||
raise RuntimeError("search_knowledge non include risultati Gitea")
|
||||
|
||||
list_tool = tools.call_tool("list_gitea_indexed_files", {"repo": repo, "limit": 5}, claims)
|
||||
if json.loads(list_tool["content"][0]["text"]).get("count", 0) <= 0:
|
||||
raise RuntimeError("list_gitea_indexed_files vuoto")
|
||||
|
||||
print("OK P3: search + unified + list (reindex opzionale via tool reindex_gitea_repo)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test di concorrenza MCP: N chiamate parallele + sonde /health e ping.
|
||||
|
||||
Uso (dentro il container):
|
||||
docker exec loogle-mcp python /srv/scripts/test_mcp_concurrency.py [N] [tool]
|
||||
|
||||
Misura se una chiamata lenta blocca le altre (event loop bloccato) e la
|
||||
latenza delle ricerche semantiche.
|
||||
"""
|
||||
|
||||
import concurrent.futures as cf
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, "/srv")
|
||||
from app.db import get_conn # noqa: E402
|
||||
from app.jwt_utils import create_access_token # noqa: E402
|
||||
|
||||
BASE = os.environ.get("TEST_BASE", "http://127.0.0.1:8700")
|
||||
N = int(sys.argv[1]) if len(sys.argv) > 1 else 4
|
||||
TOOL = sys.argv[2] if len(sys.argv) > 2 else "search_knowledge"
|
||||
CLIENT_TIMEOUT = 150.0
|
||||
|
||||
row = get_conn().execute("SELECT id, username FROM users WHERE username='daniele'").fetchone()
|
||||
token, _ = create_access_token(
|
||||
{"id": row["id"], "username": row["username"]},
|
||||
"context:read knowledge:read gitea:read admin",
|
||||
"loadtest",
|
||||
)
|
||||
HEADERS = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def rpc(method: str, params: dict, req_id: int) -> tuple[float, str]:
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
r = httpx.post(
|
||||
f"{BASE}/mcp",
|
||||
headers=HEADERS,
|
||||
json={"jsonrpc": "2.0", "id": req_id, "method": method, "params": params},
|
||||
timeout=CLIENT_TIMEOUT,
|
||||
)
|
||||
body = r.json()
|
||||
if "error" in body:
|
||||
status = f"ERR {body['error'].get('code')}: {body['error'].get('message', '')[:90]}"
|
||||
else:
|
||||
text = body["result"]["content"][0]["text"] if method == "tools/call" else "{}"
|
||||
payload = json.loads(text)
|
||||
n = len(payload.get("results", [])) if isinstance(payload, dict) else "?"
|
||||
status = f"OK results={n}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status = f"EXC {type(exc).__name__}: {exc}"[:100]
|
||||
return time.monotonic() - t0, status
|
||||
|
||||
|
||||
def health() -> float:
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
httpx.get(f"{BASE}/health", timeout=CLIENT_TIMEOUT)
|
||||
except Exception: # noqa: BLE001
|
||||
return -1.0
|
||||
return time.monotonic() - t0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
queries = ["failover keepalived", "irrigazione zone", "turni medici", "backup nas",
|
||||
"runbook", "rete vlan", "home assistant", "paperless"]
|
||||
t_start = time.monotonic()
|
||||
with cf.ThreadPoolExecutor(max_workers=N + 2) as pool:
|
||||
futs = {
|
||||
pool.submit(rpc, "tools/call",
|
||||
{"name": TOOL, "arguments": {"query": queries[i % len(queries)], "limit": 3}},
|
||||
i + 1): f"{TOOL}#{i + 1}"
|
||||
for i in range(N)
|
||||
}
|
||||
time.sleep(0.5)
|
||||
h = pool.submit(health)
|
||||
p = pool.submit(rpc, "tools/call", {"name": "ping", "arguments": {}}, 999)
|
||||
print(f"/health durante il carico: {h.result():.2f}s")
|
||||
pt, ps = p.result()
|
||||
print(f"ping durante il carico: {pt:.2f}s {ps}")
|
||||
for fut in cf.as_completed(futs):
|
||||
dt, status = fut.result()
|
||||
print(f"{futs[fut]:<24} {dt:6.2f}s {status}")
|
||||
print(f"Totale: {time.monotonic() - t_start:.2f}s (N={N})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Conformità Streamable HTTP / lifecycle MCP: negoziazione versione, 202 per notifiche, header.
|
||||
|
||||
Uso (dentro il container):
|
||||
docker exec loogle-mcp python /srv/scripts/test_mcp_protocol.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, "/srv")
|
||||
from app.db import get_conn # noqa: E402
|
||||
from app.jwt_utils import create_access_token # noqa: E402
|
||||
|
||||
BASE = os.environ.get("TEST_BASE", "http://127.0.0.1:8700") + "/mcp"
|
||||
row = get_conn().execute("SELECT id, username FROM users WHERE username='daniele'").fetchone()
|
||||
token, _ = create_access_token(dict(row), "context:read", "protocol-test")
|
||||
AUTH = {"Authorization": f"Bearer {token}"}
|
||||
failures = 0
|
||||
|
||||
|
||||
def check(label: str, cond: bool, detail: str = "") -> None:
|
||||
global failures
|
||||
failures += 0 if cond else 1
|
||||
print(f"{'OK ' if cond else 'FAIL'} {label} {detail}")
|
||||
|
||||
|
||||
def init(version):
|
||||
params = {"protocolVersion": version, "capabilities": {}, "clientInfo": {"name": "protocol-test"}}
|
||||
r = httpx.post(BASE, json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": params})
|
||||
return r.json()["result"]["protocolVersion"]
|
||||
|
||||
|
||||
for requested, expected in [("2025-06-18", "2025-06-18"), ("2025-03-26", "2025-03-26"),
|
||||
("2024-11-05", "2024-11-05"), ("2099-01-01", "2025-06-18")]:
|
||||
got = init(requested)
|
||||
check(f"initialize {requested}", got == expected, f"-> {got}")
|
||||
|
||||
r = httpx.post(BASE, json={"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
check("notifica non autenticata initialized -> 202", r.status_code == 202 and not r.content, str(r.status_code))
|
||||
r = httpx.post(BASE, headers=AUTH, json={"jsonrpc": "2.0", "method": "notifications/cancelled",
|
||||
"params": {"requestId": 5}})
|
||||
check("notifica cancelled -> 202", r.status_code == 202, str(r.status_code))
|
||||
r = httpx.post(BASE, json={"jsonrpc": "2.0", "method": "notifications/cancelled"})
|
||||
check("notifica non autenticata non ammessa -> 401", r.status_code == 401, str(r.status_code))
|
||||
r = httpx.post(BASE, headers=AUTH, json=[{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||
{"jsonrpc": "2.0", "id": 7, "method": "ping"}])
|
||||
body = r.json()
|
||||
check("batch notifica+request -> solo 1 risposta", r.status_code == 200 and len(body) == 1 and body[0]["id"] == 7,
|
||||
str(body))
|
||||
r = httpx.post(BASE, headers={**AUTH, "MCP-Protocol-Version": "2025-06-18"},
|
||||
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
|
||||
check("header versione valida -> 200", r.status_code == 200 and "tools" in r.json()["result"], str(r.status_code))
|
||||
r = httpx.post(BASE, headers={**AUTH, "MCP-Protocol-Version": "1999-01-01"},
|
||||
json={"jsonrpc": "2.0", "id": 3, "method": "tools/list"})
|
||||
check("header versione non supportata -> 400", r.status_code == 400, str(r.status_code))
|
||||
r = httpx.post(BASE, json={"jsonrpc": "2.0", "id": 4, "method": "tools/list"})
|
||||
check("request senza token -> 401 + WWW-Authenticate",
|
||||
r.status_code == 401 and "resource_metadata" in r.headers.get("www-authenticate", ""), str(r.status_code))
|
||||
r = httpx.post(BASE, headers=AUTH, json=[])
|
||||
check("batch vuoto -> 400", r.status_code == 400, str(r.status_code))
|
||||
print(f"Errori: {failures}")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regressione: chiama ogni tool MCP senza argomenti obbligatori (sola lettura) e ne misura la durata.
|
||||
|
||||
Uso (dentro il container):
|
||||
docker exec loogle-mcp python /srv/scripts/test_mcp_readonly_tools.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, "/srv")
|
||||
from app.db import get_conn # noqa: E402
|
||||
from app.jwt_utils import create_access_token # noqa: E402
|
||||
|
||||
BASE = os.environ.get("TEST_BASE", "http://127.0.0.1:8700")
|
||||
# Tool senza argomenti obbligatori ma con effetti collaterali: esclusi
|
||||
SKIP = {"reindex_apps"}
|
||||
|
||||
row = get_conn().execute("SELECT id, username FROM users WHERE username='daniele'").fetchone()
|
||||
scope = ("context:read knowledge:read gitea:read home:read irrigation:read turni:read admin")
|
||||
token, _ = create_access_token({"id": row["id"], "username": row["username"]}, scope, "regression")
|
||||
HEADERS = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def rpc(method: str, params: dict) -> dict:
|
||||
r = httpx.post(f"{BASE}/mcp", headers=HEADERS, timeout=120,
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
|
||||
return r.json()
|
||||
|
||||
|
||||
tools = rpc("tools/list", {})["result"]["tools"]
|
||||
failures = 0
|
||||
for tool in tools:
|
||||
if tool["inputSchema"].get("required") or tool["name"] in SKIP:
|
||||
continue
|
||||
t0 = time.monotonic()
|
||||
body = rpc("tools/call", {"name": tool["name"], "arguments": {}})
|
||||
dt = time.monotonic() - t0
|
||||
if "error" in body:
|
||||
status = f"ERR {body['error']['message'][:80]}"
|
||||
failures += 1
|
||||
else:
|
||||
status = f"OK {len(body['result']['content'][0]['text'])} bytes"
|
||||
print(f"{tool['name']:<30} {dt:6.2f}s {status}")
|
||||
print(f"Tool totali: {len(tools)}, errori: {failures}")
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Verifica P5: tool live Loogle Casa + Home Assistant."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from app.integrations import casa, homeassistant
|
||||
from app.mcp import tools
|
||||
|
||||
user = os.environ.get("MCP_TEST_USER", "daniele")
|
||||
claims = {
|
||||
"sub": user,
|
||||
"scope": "home:read context:read knowledge:read gitea:read admin",
|
||||
}
|
||||
errors = []
|
||||
|
||||
print("=== P5 integrations ===")
|
||||
print("casa configured:", casa.is_configured(user))
|
||||
print("ha configured:", homeassistant.is_configured())
|
||||
|
||||
if casa.is_configured(user):
|
||||
for tool, args in (
|
||||
("get_home_dashboard", {}),
|
||||
("get_home_weather", {}),
|
||||
("get_network_overview", {}),
|
||||
("get_network_failover_status", {}),
|
||||
):
|
||||
try:
|
||||
out = tools.call_tool(tool, args, claims)
|
||||
text = out["content"][0]["text"]
|
||||
data = json.loads(text)
|
||||
print(f"OK {tool}: keys={list(data.keys())[:8]}")
|
||||
except Exception as exc:
|
||||
print(f"FAIL {tool}: {exc}")
|
||||
errors.append(tool)
|
||||
else:
|
||||
errors.append("casa-not-configured")
|
||||
|
||||
if homeassistant.is_configured():
|
||||
try:
|
||||
cfg = homeassistant.get_config()
|
||||
print(f"OK ha config: version={cfg.get('version')}")
|
||||
except Exception as exc:
|
||||
print(f"FAIL ha config: {exc}")
|
||||
errors.append("ha-config")
|
||||
try:
|
||||
out = tools.call_tool("list_ha_entities", {"domain": "switch", "limit": 5}, claims)
|
||||
data = json.loads(out["content"][0]["text"])
|
||||
print(f"OK list_ha_entities: count={len(data.get('entities', []))}")
|
||||
except Exception as exc:
|
||||
print(f"FAIL list_ha_entities: {exc}")
|
||||
errors.append("list_ha_entities")
|
||||
else:
|
||||
errors.append("ha-not-configured")
|
||||
|
||||
if errors:
|
||||
print("P5 FAILED:", errors)
|
||||
return 1
|
||||
print("P5 OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Verifica P6: tool live Irrigazione + Turni."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from app.integrations import irrigazione, turni
|
||||
from app.mcp import tools
|
||||
|
||||
user = os.environ.get("MCP_TEST_USER", "daniele")
|
||||
claims = {
|
||||
"sub": user,
|
||||
"scope": "irrigation:read turni:read home:read admin",
|
||||
}
|
||||
errors = []
|
||||
|
||||
print("=== P6 integrations ===")
|
||||
print("irrigazione configured:", irrigazione.is_configured(user))
|
||||
print("turni configured:", turni.is_configured(user))
|
||||
|
||||
if irrigazione.is_configured(user):
|
||||
for tool, args in (
|
||||
("get_irrigation_status", {}),
|
||||
("get_irrigation_zones", {}),
|
||||
("get_irrigation_history", {"limit": 5}),
|
||||
):
|
||||
try:
|
||||
out = tools.call_tool(tool, args, claims)
|
||||
data = json.loads(out["content"][0]["text"])
|
||||
print(f"OK {tool}: type={type(data).__name__}")
|
||||
except Exception as exc:
|
||||
print(f"FAIL {tool}: {exc}")
|
||||
errors.append(tool)
|
||||
else:
|
||||
errors.append("irrigazione-not-configured")
|
||||
|
||||
try:
|
||||
out = tools.call_tool("get_turni_status", {}, claims)
|
||||
data = json.loads(out["content"][0]["text"])
|
||||
print(f"OK get_turni_status: build={data.get('buildVersion', '')[:30]}")
|
||||
except Exception as exc:
|
||||
print(f"FAIL get_turni_status: {exc}")
|
||||
errors.append("get_turni_status")
|
||||
|
||||
if turni.is_configured(user):
|
||||
for tool, args in (
|
||||
("list_turni_doctors", {}),
|
||||
("get_my_shifts", {"limit": 10}),
|
||||
):
|
||||
try:
|
||||
out = tools.call_tool(tool, args, claims)
|
||||
data = json.loads(out["content"][0]["text"])
|
||||
print(f"OK {tool}: keys={list(data.keys())[:6]}")
|
||||
except Exception as exc:
|
||||
print(f"FAIL {tool}: {exc}")
|
||||
errors.append(tool)
|
||||
else:
|
||||
errors.append("turni-not-configured")
|
||||
|
||||
if errors:
|
||||
print("P6 FAILED:", errors)
|
||||
return 1
|
||||
print("P6 OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Verifica P7: RAG Irrigazione + Turni."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from app.db import init_db
|
||||
from app.knowledge import apps_indexer, indexer
|
||||
from app.mcp import tools
|
||||
|
||||
init_db()
|
||||
user = os.environ.get("MCP_TEST_USER", "daniele")
|
||||
claims = {
|
||||
"sub": user,
|
||||
"scope": "knowledge:read irrigation:read turni:read admin",
|
||||
}
|
||||
errors = []
|
||||
|
||||
print("=== P7 apps RAG ===")
|
||||
existing = apps_indexer.list_indexed_records(limit=5)
|
||||
if len(existing) >= 2:
|
||||
print(f"skip re-index: {len(existing)} records already present")
|
||||
result = {"irrigazione": {"indexed": len(existing), "skipped_reindex": True}, "turni": {}}
|
||||
else:
|
||||
result = {
|
||||
"irrigazione": apps_indexer.index_irrigazione(history_limit=0, events_limit=0),
|
||||
"turni": apps_indexer.index_turni(assignments_limit=3),
|
||||
}
|
||||
print("index_all:", json.dumps(result, ensure_ascii=False)[:500])
|
||||
|
||||
irr = result.get("irrigazione", {})
|
||||
turn = result.get("turni", {})
|
||||
if irr.get("skipped") and turn.get("skipped"):
|
||||
print("P7 FAILED: both sources skipped")
|
||||
return 1
|
||||
if irr.get("indexed", 0) + turn.get("indexed", 0) < 2 and not irr.get("skipped_reindex"):
|
||||
errors.append("insufficient-indexed-records")
|
||||
|
||||
records = apps_indexer.list_indexed_records(limit=10)
|
||||
print(f"indexed records: {len(records)}")
|
||||
if not records:
|
||||
errors.append("no-records")
|
||||
|
||||
try:
|
||||
hits = apps_indexer.search_apps_knowledge("irrigazione zona prato", limit=3)
|
||||
print(f"search_apps_knowledge irrigazione: {len(hits)} hits")
|
||||
if not hits:
|
||||
errors.append("search-irrigazione-empty")
|
||||
except Exception as exc:
|
||||
print(f"FAIL search_apps: {exc}")
|
||||
errors.append("search_apps")
|
||||
|
||||
try:
|
||||
out = tools.call_tool("search_apps_knowledge", {"query": "turno guardia", "limit": 3}, claims)
|
||||
data = json.loads(out["content"][0]["text"])
|
||||
print(f"OK tool search_apps_knowledge: {len(data.get('results', []))} hits")
|
||||
except Exception as exc:
|
||||
print(f"FAIL tool search_apps_knowledge: {exc}")
|
||||
errors.append("tool-search")
|
||||
|
||||
try:
|
||||
combined = indexer.search_knowledge(user, "irrigazione valvola", limit=5, is_admin=True)
|
||||
apps_hits = [h for h in combined if h.get("source") in ("irrigazione", "turni")]
|
||||
print(f"search_knowledge includes apps: {len(apps_hits)} app hits / {len(combined)} total")
|
||||
except Exception as exc:
|
||||
print(f"FAIL search_knowledge: {exc}")
|
||||
errors.append("search_knowledge")
|
||||
|
||||
if errors:
|
||||
print("P7 FAILED:", errors)
|
||||
return 1
|
||||
print("P7 OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from app.db import init_db
|
||||
from app.knowledge.apps_indexer import (
|
||||
_summarize_irrigation_status,
|
||||
_index_text,
|
||||
list_indexed_records,
|
||||
search_apps_knowledge,
|
||||
)
|
||||
from app.integrations import irrigazione
|
||||
|
||||
init_db()
|
||||
print("1 db ok")
|
||||
existing = list_indexed_records(source="irrigazione", limit=1)
|
||||
if existing and existing[0].get("record_id") == "status-snapshot":
|
||||
print("2 skip index (status-snapshot exists)")
|
||||
else:
|
||||
s = irrigazione.get_status("daniele")
|
||||
print("3 status ok", len(s))
|
||||
text = _summarize_irrigation_status(s)
|
||||
print("4 summary", len(text))
|
||||
r = _index_text("irrigazione", "status-snapshot", "Stato", text)
|
||||
print("5 indexed", r)
|
||||
hits = search_apps_knowledge("irrigazione zona", limit=2)
|
||||
print("6 hits", len(hits))
|
||||
print("STEP OK")
|
||||
@@ -1,154 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test P2: collegamento progetti MCP ↔ repository Gitea."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
os.environ["MCP_DB"] = "/tmp/loogle_mcp_p2_test.db"
|
||||
TEST_ROOT = tempfile.mkdtemp(prefix="mcp-p2-")
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
env_path = os.path.join(ROOT, ".env")
|
||||
if not os.path.isfile(env_path):
|
||||
return
|
||||
with open(env_path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), value.strip().strip("'").strip('"'))
|
||||
|
||||
|
||||
def run_unit_tests() -> None:
|
||||
os.environ["MCP_CONTEXT_ROOT"] = TEST_ROOT
|
||||
from app.db import init_db
|
||||
from app.context import store as context_store
|
||||
from app.mcp import tools
|
||||
|
||||
init_db()
|
||||
claims = {
|
||||
"sub": "daniele",
|
||||
"scope": "context:read context:write gitea:read gitea:write",
|
||||
}
|
||||
|
||||
meta = context_store.create_project(
|
||||
"daniele",
|
||||
"Progetto unit test",
|
||||
tags=["test"],
|
||||
)
|
||||
meta_path = os.path.join(TEST_ROOT, "daniele", "projects", meta["id"], "meta.json")
|
||||
meta["gitea_repo"] = "daniele/rete"
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
|
||||
ctx = tools.call_tool(
|
||||
"get_project_context",
|
||||
{"project_id": meta["id"], "include_gitea": False},
|
||||
claims,
|
||||
)
|
||||
payload = json.loads(ctx["content"][0]["text"])
|
||||
assert payload["meta"]["gitea_repo"] == "daniele/rete"
|
||||
|
||||
unlinked = tools.call_tool(
|
||||
"link_project_repo",
|
||||
{"project_id": meta["id"], "gitea_repo": ""},
|
||||
claims,
|
||||
)
|
||||
payload = json.loads(unlinked["content"][0]["text"])
|
||||
assert "gitea_repo" not in payload
|
||||
|
||||
print("OK unit: meta gitea_repo + scollegamento locale")
|
||||
|
||||
|
||||
def run_live_tests() -> None:
|
||||
_load_dotenv()
|
||||
token = os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip()
|
||||
if not token:
|
||||
print("SKIP live: GITEA_API_TOKEN_DANIELE assente")
|
||||
return
|
||||
|
||||
live_root = os.path.join(TEST_ROOT, "live")
|
||||
os.makedirs(live_root, exist_ok=True)
|
||||
os.environ["MCP_CONTEXT_ROOT"] = live_root
|
||||
|
||||
from app.context import store as context_store
|
||||
from app.mcp import tools
|
||||
from app.knowledge import gitea
|
||||
|
||||
gitea._tokens_cache = None
|
||||
claims = {
|
||||
"sub": "daniele",
|
||||
"scope": "context:read context:write gitea:read gitea:write",
|
||||
}
|
||||
|
||||
project_id = "rete-ha-p2-test"
|
||||
proj_dir = os.path.join(live_root, "daniele", "projects", project_id)
|
||||
if os.path.isdir(proj_dir):
|
||||
shutil.rmtree(proj_dir)
|
||||
|
||||
created = tools.call_tool(
|
||||
"create_project",
|
||||
{
|
||||
"title": "Rete HA P2 test",
|
||||
"tags": ["infra", "test"],
|
||||
"gitea_repo": "daniele/rete",
|
||||
"seed_from_gitea": True,
|
||||
},
|
||||
claims,
|
||||
)
|
||||
payload = json.loads(created["content"][0]["text"])
|
||||
project_id = payload["id"]
|
||||
assert payload.get("gitea_repo") == "daniele/rete"
|
||||
assert payload.get("gitea_seeded_at")
|
||||
|
||||
ctx_path = os.path.join(live_root, "daniele", "projects", project_id, "context.md")
|
||||
context_md = open(ctx_path, encoding="utf-8").read()
|
||||
assert "seed:gitea daniele/rete" in context_md
|
||||
assert len(context_md) > 200
|
||||
|
||||
enriched = tools.call_tool(
|
||||
"get_project_context",
|
||||
{"project_id": project_id, "include_gitea": True, "session_limit": 0},
|
||||
claims,
|
||||
)
|
||||
data = json.loads(enriched["content"][0]["text"])
|
||||
assert data["meta"]["gitea_repo"] == "daniele/rete"
|
||||
assert data["gitea"]["available"] is True
|
||||
assert data["gitea"]["readme"] is not None
|
||||
assert isinstance(data["gitea"].get("docs_files"), list)
|
||||
|
||||
link_existing = tools.call_tool(
|
||||
"link_project_repo",
|
||||
{"project_id": project_id, "gitea_repo": "daniele/rete", "seed_from_gitea": True},
|
||||
claims,
|
||||
)
|
||||
meta2 = json.loads(link_existing["content"][0]["text"])
|
||||
assert "gitea_seeded_at" in meta2
|
||||
|
||||
shutil.rmtree(proj_dir)
|
||||
print(f"OK live: create+seed+enrichment su daniele/rete (project {project_id})")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
run_unit_tests()
|
||||
run_live_tests()
|
||||
return 0
|
||||
finally:
|
||||
shutil.rmtree(TEST_ROOT, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,115 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test dei contesti condivisi su directory e DB temporanei (non tocca i dati reali).
|
||||
|
||||
Uso (dentro il container):
|
||||
docker exec loogle-mcp python /srv/scripts/test_shared_contexts.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
TMP = tempfile.mkdtemp(prefix="shared-ctx-test-")
|
||||
os.environ["MCP_CONTEXT_ROOT"] = os.path.join(TMP, "context")
|
||||
os.environ["MCP_DB"] = os.path.join(TMP, "test.db")
|
||||
sys.path.insert(0, "/srv")
|
||||
|
||||
from app.db import init_db # noqa: E402
|
||||
from app.context import store # noqa: E402
|
||||
from app.knowledge import indexer # noqa: E402
|
||||
from app.mcp import tools # noqa: E402
|
||||
|
||||
init_db()
|
||||
queued = []
|
||||
indexer.submit_background = lambda label, fn, *a, **k: queued.append((label, fn.__name__, a))
|
||||
failures = 0
|
||||
|
||||
|
||||
def claims(user, admin=False):
|
||||
scope = "context:read context:write" + (" admin" if admin else "")
|
||||
return {"sub": user, "scope": scope}
|
||||
|
||||
|
||||
def call(user, name, args, admin=False):
|
||||
out = tools.call_tool(name, args, claims(user, admin))
|
||||
return json.loads(out["content"][0]["text"])
|
||||
|
||||
|
||||
def check(label, cond):
|
||||
global failures
|
||||
failures += 0 if cond else 1
|
||||
print(f"{'OK ' if cond else 'FAIL'} {label}")
|
||||
|
||||
|
||||
def raises(exc, fn, *a, **k):
|
||||
try:
|
||||
fn(*a, **k)
|
||||
except exc:
|
||||
return True
|
||||
except Exception as other: # noqa: BLE001
|
||||
print(" eccezione inattesa:", type(other).__name__, other)
|
||||
return False
|
||||
|
||||
|
||||
# 1. Progetto personale di daniele, poi condiviso
|
||||
p = call("daniele", "create_project", {"title": "Stampa 3D X2D", "tags": ["stampa-3d"]})
|
||||
pid = p["id"]
|
||||
call("daniele", "save_context", {"project_id": pid, "content": "## Dotazione\n- PLA 4 kg"})
|
||||
check("lucia non vede il progetto personale di daniele",
|
||||
pid not in [x["id"] for x in call("lucia", "list_projects", {})["projects"]])
|
||||
check("lucia non può leggerlo", raises(FileNotFoundError, call, "lucia", "get_project_context", {"project_id": pid}))
|
||||
m = call("daniele", "share_project", {"project_id": pid})
|
||||
check("share_project -> shared/family/owner", m.get("shared") and m.get("shared_with") == "family" and m.get("owner") == "daniele")
|
||||
check("la cartella è passata nello spazio condiviso",
|
||||
os.path.isfile(os.path.join(TMP, "context", "_shared", "projects", pid, "context.md"))
|
||||
and not os.path.exists(os.path.join(TMP, "context", "daniele", "projects", pid)))
|
||||
check("reindicizzazione vettori accodata", any(q[1] == "_move_project_vectors" for q in queued))
|
||||
|
||||
# 2. Tutta la famiglia legge e aggiorna
|
||||
for u in ("lucia", "davide", "luca"):
|
||||
ids = {x["id"]: x for x in call(u, "list_projects", {})["projects"]}
|
||||
check(f"{u} vede il contesto condiviso", pid in ids and ids[pid].get("shared"))
|
||||
ctx = call("lucia", "get_project_context", {"project_id": pid})
|
||||
check("lucia legge la storia (sessione di daniele)", "PLA 4 kg" in ctx["context_md"] and ctx["recent_sessions"])
|
||||
rev = ctx["meta"].get("revision", 0)
|
||||
m = call("lucia", "save_context", {"project_id": pid, "mode": "replace",
|
||||
"content": "## Dotazione\n- PLA 4 kg\n- PETG 1 kg (comprato da Lucia)",
|
||||
"if_revision": rev})
|
||||
check("lucia aggiorna, updated_by=lucia", m.get("updated_by") == "lucia")
|
||||
check("daniele vede la modifica di lucia", "PETG 1 kg" in call("daniele", "get_project_context", {"project_id": pid})["context_md"])
|
||||
sessions = os.listdir(os.path.join(TMP, "context", "_shared", "projects", pid, "sessions"))
|
||||
check("il nome della sessione indica l'autore", any(s.endswith("-lucia.md") for s in sessions))
|
||||
check("conflitto: if_revision superata -> errore e nessuna sovrascrittura",
|
||||
raises(store.ConflictError, store.save_context, "davide", pid, "x", "replace", if_revision=rev)
|
||||
and "PETG 1 kg" in call("davide", "get_project_context", {"project_id": pid})["context_md"])
|
||||
check("indicizzazione del contesto completo accodata", any(q[1] == "index_project_context" for q in queued))
|
||||
|
||||
# 3. Permessi: archiviare / rendere personale
|
||||
check("lucia non può archiviare", raises(PermissionError, call, "lucia", "archive_project", {"project_id": pid}))
|
||||
check("lucia non può riportarlo personale", raises(PermissionError, call, "lucia", "share_project", {"project_id": pid, "shared": False}))
|
||||
check("admin può archiviare", call("davide", "archive_project", {"project_id": pid}, admin=True).get("archived") is True)
|
||||
call("daniele", "archive_project", {"project_id": pid, "archived": False})
|
||||
m = call("daniele", "share_project", {"project_id": pid, "shared": False})
|
||||
check("il proprietario lo riporta personale", not m.get("shared") and "owner" not in m)
|
||||
check("dopo: lucia non lo vede più", pid not in [x["id"] for x in call("lucia", "list_projects", {})["projects"]])
|
||||
check("dopo: daniele lo ha di nuovo personale con la storia",
|
||||
"PETG 1 kg" in call("daniele", "get_project_context", {"project_id": pid})["context_md"])
|
||||
|
||||
# 4. Creazione diretta condivisa, ID unici tra i due spazi, risorse MCP
|
||||
s = call("lucia", "create_project", {"title": "Stampa 3D X2D", "shared": True})
|
||||
check("create_project shared=true con ID diverso dal personale di daniele", s.get("shared") and s["id"] != pid)
|
||||
check("daniele raggiunge sia il suo personale sia il nuovo condiviso",
|
||||
call("daniele", "get_project_context", {"project_id": s["id"]})["meta"].get("shared")
|
||||
and not call("daniele", "get_project_context", {"project_id": pid})["meta"].get("shared"))
|
||||
call("luca", "create_project", {"title": "Omonimo"})
|
||||
call("daniele", "create_project", {"title": "Omonimo"})
|
||||
check("share_project rifiuta un ID usato da un personale di un altro utente",
|
||||
raises(ValueError, call, "daniele", "share_project", {"project_id": "omonimo"}))
|
||||
uris = [r["uri"] for r in tools.list_resources(claims("luca"))]
|
||||
check("risorsa condivisa visibile a luca", f"loogle://context/_shared/{s['id']}" in uris)
|
||||
res = tools.read_resource(f"loogle://context/_shared/{s['id']}", claims("luca"))
|
||||
check("luca legge la risorsa condivisa", res["text"].startswith("# Stampa 3D X2D"))
|
||||
|
||||
print(f"Errori: {failures}")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
os.environ.setdefault('MCP_DB','/data/loogle_mcp.db')
|
||||
os.environ.setdefault('MCP_VECTOR_FALLBACK','/data/vector_fallback.db')
|
||||
from app.db import init_db, get_conn
|
||||
from app.knowledge import gitea_indexer
|
||||
from app.mcp import tools
|
||||
init_db()
|
||||
assert get_conn().execute("SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'").fetchone()
|
||||
repo='daniele/rete'
|
||||
for path in ['ha/FAILOVER-VOLUMES.md','ha/RUNBOOK-failover.md']:
|
||||
r=gitea_indexer.index_file(repo, path, username='daniele', private=True, force=True)
|
||||
print('indexed', path, r.get('chunks'), r.get('skipped'), flush=True)
|
||||
files=gitea_indexer.list_indexed_files(limit=10, repo=repo)
|
||||
print('db_files', [f['path'] for f in files], flush=True)
|
||||
for q in ('failover tier-b','runbook'):
|
||||
hits=gitea_indexer.search_gitea_knowledge('daniele', q, limit=3, is_admin=True)
|
||||
print('search', q, hits[0]['path'] if hits else None, round(float(hits[0]['score']),3) if hits else None, flush=True)
|
||||
if not hits: sys.exit(1)
|
||||
claims={'sub':'daniele','scope':'knowledge:read gitea:read admin'}
|
||||
for tool,args in [
|
||||
('search_gitea_knowledge',{'query':'failover tier-b','limit':5}),
|
||||
('list_gitea_indexed_files',{'repo':repo,'limit':10}),
|
||||
('reindex_gitea_repo',{'repo':repo,'max_files':1}),
|
||||
('search_knowledge',{'query':'failover keepalived','limit':8}),
|
||||
]:
|
||||
out=tools.call_tool(tool,args,claims)
|
||||
p=json.loads(out['content'][0]['text'])
|
||||
if tool=='search_knowledge':
|
||||
src=sorted({r.get('source') for r in p['results']})
|
||||
print(tool, src, flush=True)
|
||||
assert 'gitea' in src
|
||||
elif tool=='list_gitea_indexed_files':
|
||||
print(tool, p['count'], flush=True)
|
||||
else:
|
||||
print(tool, 'ok', flush=True)
|
||||
print('OK P3 COMPLETE', flush=True)
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
os.environ.setdefault('MCP_DB','/data/loogle_mcp.db')
|
||||
os.environ.setdefault('MCP_VECTOR_FALLBACK','/data/vector_fallback.db')
|
||||
from app.db import init_db, get_conn
|
||||
from app.knowledge import gitea_indexer
|
||||
from app.mcp import tools
|
||||
init_db()
|
||||
print('table_ok', bool(get_conn().execute("SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'").fetchone()), flush=True)
|
||||
files=gitea_indexer.list_indexed_files(limit=10, repo='daniele/rete')
|
||||
print('indexed', [f['path'] for f in files], flush=True)
|
||||
if not files:
|
||||
print('WARN: nessun file indicizzato — indicizzo stacks.conf (piccolo)', flush=True)
|
||||
r=gitea_indexer.index_file('daniele/rete','ha/stacks.conf', username='daniele', private=True, force=True)
|
||||
print('new_index', r, flush=True)
|
||||
files=gitea_indexer.list_indexed_files(limit=10, repo='daniele/rete')
|
||||
for q in ('failover','censimento','tier-b'):
|
||||
hits=gitea_indexer.search_gitea_knowledge('daniele', q, limit=3, is_admin=True)
|
||||
print('search', q, hits[0]['path'] if hits else 'NONE', round(float(hits[0]['score']),3) if hits else None, flush=True)
|
||||
if not hits and q=='failover':
|
||||
sys.exit(1)
|
||||
claims={'sub':'daniele','scope':'knowledge:read gitea:read admin'}
|
||||
sg=tools.call_tool('search_gitea_knowledge',{'query':'failover tier-b','limit':5},claims)
|
||||
assert json.loads(sg['content'][0]['text'])['results']
|
||||
print('tool search_gitea_knowledge ok', flush=True)
|
||||
li=tools.call_tool('list_gitea_indexed_files',{'repo':'daniele/rete','limit':10},claims)
|
||||
print('tool list', json.loads(li['content'][0]['text'])['count'], flush=True)
|
||||
ri=tools.call_tool('reindex_gitea_repo',{'repo':'daniele/rete','max_files':1},claims)
|
||||
print('tool reindex', json.loads(ri['content'][0]['text']).get('files_indexed'), flush=True)
|
||||
sk=tools.call_tool('search_knowledge',{'query':'failover keepalived','limit':8},claims)
|
||||
src=sorted({r.get('source') for r in json.loads(sk['content'][0]['text'])['results']})
|
||||
print('tool search_knowledge sources', src, flush=True)
|
||||
assert 'gitea' in src
|
||||
print('OK P3 SEARCH VERIFIED', flush=True)
|
||||
@@ -1,86 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Background indexer worker — cicli separati Paperless / Gitea / Apps."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
LOGGER = logging.getLogger("loogle_mcp.worker")
|
||||
|
||||
from app.db import init_db # noqa: E402
|
||||
from app.knowledge import apps_indexer, gitea_indexer, indexer # noqa: E402
|
||||
|
||||
|
||||
def _run_gitea() -> None:
|
||||
if os.environ.get("GITEA_INDEX_ENABLED", "yes").strip().lower() in ("0", "false", "no", "off"):
|
||||
LOGGER.info("Indicizzazione Gitea disabilitata (GITEA_INDEX_ENABLED)")
|
||||
return
|
||||
gitea_result = gitea_indexer.index_all()
|
||||
stats = gitea_indexer.index_stats()
|
||||
LOGGER.info("Indicizzazione Gitea completata: %s stats=%s", gitea_result, stats)
|
||||
|
||||
|
||||
def _run_paperless() -> None:
|
||||
result = indexer.index_all(max_pages=10)
|
||||
LOGGER.info("Indicizzazione Paperless completata: %s", result)
|
||||
|
||||
|
||||
def _run_apps() -> None:
|
||||
apps_result = apps_indexer.index_all()
|
||||
LOGGER.info("Indicizzazione Apps (P7) completata: %s", apps_result)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
init_db()
|
||||
interval = int(os.environ.get("INDEXER_INTERVAL_MINUTES", "30")) * 60
|
||||
LOGGER.info("Indexer avviato, intervallo %ds", interval)
|
||||
|
||||
from app.knowledge import thermal # noqa: WPS433
|
||||
|
||||
status = thermal.read_status()
|
||||
if status:
|
||||
LOGGER.info(
|
||||
"Thermal probe OK: temp=%.1f°C load1=%.2f nproc=%s source=%s (hard=%.0f°C soft=%.0f°C cpu_target=%.0f%%)",
|
||||
float(status.get("cpu_temp_c") or 0),
|
||||
float(status.get("load1") or 0),
|
||||
status.get("nproc"),
|
||||
status.get("source"),
|
||||
thermal.hard_temp_c(),
|
||||
thermal.soft_temp_c(),
|
||||
thermal.cpu_target_pct(),
|
||||
)
|
||||
else:
|
||||
LOGGER.warning("Thermal probe non raggiungibile all'avvio — gate userà delay conservativi")
|
||||
|
||||
# Gitea per primo: repo piccoli, non bloccato da Paperless lento
|
||||
try:
|
||||
thermal.wait_for_headroom(context="cycle:gitea-boot")
|
||||
_run_gitea()
|
||||
except Exception as exc:
|
||||
LOGGER.error("Indicizzazione Gitea fallita: %s", exc)
|
||||
|
||||
while True:
|
||||
try:
|
||||
thermal.wait_for_headroom(context="cycle:gitea")
|
||||
_run_gitea()
|
||||
except Exception as exc:
|
||||
LOGGER.error("Indicizzazione Gitea fallita: %s", exc)
|
||||
|
||||
try:
|
||||
thermal.wait_for_headroom(context="cycle:paperless")
|
||||
_run_paperless()
|
||||
except Exception as exc:
|
||||
LOGGER.error("Indicizzazione Paperless fallita: %s", exc)
|
||||
|
||||
try:
|
||||
thermal.wait_for_headroom(context="cycle:apps")
|
||||
_run_apps()
|
||||
except Exception as exc:
|
||||
LOGGER.error("Indicizzazione Apps fallita: %s", exc)
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more