Compare commits
4
Commits
beb790ed7e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35677497dc | ||
|
|
aab8502e27 | ||
|
|
795a15a7b6 | ||
|
|
11823447a2 |
No files matched your search
@@ -17,8 +17,12 @@ vrrp_instance VI_1 {
|
|||||||
interface eth0
|
interface eth0
|
||||||
virtual_router_id 51
|
virtual_router_id 51
|
||||||
priority 101
|
priority 101
|
||||||
advert_int 1
|
advert_int 2
|
||||||
preempt_delay 30
|
preempt_delay 30
|
||||||
|
unicast_src_ip 192.168.128.80
|
||||||
|
unicast_peer {
|
||||||
|
192.168.128.81
|
||||||
|
}
|
||||||
authentication {
|
authentication {
|
||||||
auth_type PASS
|
auth_type PASS
|
||||||
auth_pass @Dedelove1
|
auth_pass @Dedelove1
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ vrrp_instance VI_1 {
|
|||||||
interface eth0
|
interface eth0
|
||||||
virtual_router_id 51
|
virtual_router_id 51
|
||||||
priority 100
|
priority 100
|
||||||
advert_int 1
|
advert_int 2
|
||||||
preempt_delay 30
|
preempt_delay 30
|
||||||
|
unicast_src_ip 192.168.128.81
|
||||||
|
unicast_peer {
|
||||||
|
192.168.128.80
|
||||||
|
}
|
||||||
authentication {
|
authentication {
|
||||||
auth_type PASS
|
auth_type PASS
|
||||||
auth_pass @Dedelove1
|
auth_pass @Dedelove1
|
||||||
|
|||||||
@@ -5,3 +5,5 @@
|
|||||||
# Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2)
|
# Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2)
|
||||||
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
|
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
|
||||||
# turni-app:live-latest
|
# turni-app:live-latest
|
||||||
|
# Dopo Watchtower: pull registry da rete/compose/failover anche senza container.
|
||||||
|
# Override: FAILOVER_COMPOSE_DIR=...
|
||||||
@@ -6,3 +6,5 @@
|
|||||||
# Cron consigliato: 40 0 * * 6 (tra irrigazione serale ~19:30 e notturna ~02:30)
|
# 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:
|
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
|
||||||
# irrigazione, turni-app:beta/alpha, meteo-alert, loogle-casa, ewelink_smart_home
|
# 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=...
|
||||||
@@ -20,6 +20,8 @@ PIHOLE_BIN="/usr/local/bin/pihole"
|
|||||||
REBOOT_DELAY_MIN=2
|
REBOOT_DELAY_MIN=2
|
||||||
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
|
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
|
||||||
WATCHTOWER_TIMEOUT=900
|
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)"
|
HOST_LABEL="$(hostname -s)"
|
||||||
REBOOT_ON_SUCCESS=true
|
REBOOT_ON_SUCCESS=true
|
||||||
@@ -218,6 +220,99 @@ run_watchtower() {
|
|||||||
fi
|
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() {
|
audit_apt() {
|
||||||
local holds
|
local holds
|
||||||
holds=$(apt-mark showhold 2>/dev/null || true)
|
holds=$(apt-mark showhold 2>/dev/null || true)
|
||||||
@@ -442,6 +537,8 @@ audit_eeprom
|
|||||||
|
|
||||||
# 4. Aggiornamento container Docker (Watchtower run-once; il daemon è MONITOR_ONLY)
|
# 4. Aggiornamento container Docker (Watchtower run-once; il daemon è MONITOR_ONLY)
|
||||||
run_watchtower
|
run_watchtower
|
||||||
|
# 4b. Immagini registry dei compose failover (Paperless, Vaultwarden, …) anche se non c'è container
|
||||||
|
pull_failover_standby_images
|
||||||
|
|
||||||
# 5. Audit residui
|
# 5. Audit residui
|
||||||
audit_docker
|
audit_docker
|
||||||
|
|||||||
@@ -1,36 +1,47 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 💾 AUTO GIT BACKUP - DINAMICO
|
# AUTO GIT BACKUP - DINAMICO
|
||||||
# Sincronizza automaticamente tutti gli script .sh e .py dal Pi2 e dal Pi1
|
# Sincronizza automaticamente tutti gli script .sh e .py dal Pi2 e dal Pi1
|
||||||
# verso il repository Gitea locale, poi esegue il push.
|
# verso il repository Gitea locale, poi esegue il push.
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
# --- CONFIGURAZIONE ---
|
# --- CONFIGURAZIONE ---
|
||||||
REPO_DIR="/home/daniely/loogle-repo"
|
REPO_DIR="/home/daniely/loogle-repo"
|
||||||
LOG_FILE="/var/log/git-backup.log"
|
LOG_FILE="/var/log/git-backup.log"
|
||||||
DATE=$(date +"%Y-%m-%d %H:%M")
|
DATE=$(date +"%Y-%m-%d %H:%M")
|
||||||
|
export GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=10"
|
||||||
|
|
||||||
# IP del Pi1 (Master) da cui prelevare i file remoti
|
# IP del Pi1 (Master) da cui prelevare i file remoti
|
||||||
PI1_IP="192.168.128.80"
|
PI1_IP="192.168.128.80"
|
||||||
PI1_USER="daniely"
|
PI1_USER="daniely"
|
||||||
|
|
||||||
# Redirige tutto l'output (stdout e stderr) nel log
|
# Redirige tutto l'output (stdout e stderr) nel log
|
||||||
exec >> $LOG_FILE 2>&1
|
exec >> "$LOG_FILE" 2>&1
|
||||||
|
|
||||||
echo "=== Inizio Backup Git: $DATE ==="
|
echo "=== Inizio Backup Git: $DATE ==="
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "❌ $1"
|
||||||
|
echo "=== Fine Backup Git (ERRORE) ==="
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# 1. PREPARAZIONE REPOSITORY
|
# 1. PREPARAZIONE REPOSITORY
|
||||||
# --------------------------
|
# --------------------------
|
||||||
if [ -d "$REPO_DIR" ]; then
|
if [ -d "$REPO_DIR" ]; then
|
||||||
cd "$REPO_DIR" || { echo "❌ Errore: Impossibile entrare in $REPO_DIR"; exit 1; }
|
cd "$REPO_DIR" || fail "Impossibile entrare in $REPO_DIR"
|
||||||
else
|
else
|
||||||
echo "❌ Errore: La cartella $REPO_DIR non esiste."; exit 1;
|
fail "La cartella $REPO_DIR non esiste."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Aggiorna il repository locale (pull) per evitare conflitti
|
# Aggiorna il repository locale (pull) per evitare conflitti
|
||||||
echo "🔄 Eseguo Git Pull..."
|
echo "🔄 Eseguo Git Pull..."
|
||||||
git pull origin main
|
if ! git pull --ff-only origin main; then
|
||||||
|
echo "⚠️ Git pull fallito (proseguo con commit locale se possibile)."
|
||||||
|
fi
|
||||||
|
|
||||||
# Assicuriamoci che le cartelle di destinazione esistano
|
# Assicuriamoci che le cartelle di destinazione esistano
|
||||||
mkdir -p ./scripts/pi2-backup
|
mkdir -p ./scripts/pi2-backup
|
||||||
@@ -43,7 +54,6 @@ mkdir -p ./configs
|
|||||||
echo "📂 Raccolta dinamica file locali (Pi-2)..."
|
echo "📂 Raccolta dinamica file locali (Pi-2)..."
|
||||||
|
|
||||||
# A. Script nella Home (solo .sh) -> scripts/pi2-backup
|
# A. Script nella Home (solo .sh) -> scripts/pi2-backup
|
||||||
# --include='*.sh' prende gli script, --exclude='*' ignora tutto il resto
|
|
||||||
rsync -av --include='*.sh' --exclude='*' /home/daniely/ "$REPO_DIR/scripts/pi2-backup/"
|
rsync -av --include='*.sh' --exclude='*' /home/daniely/ "$REPO_DIR/scripts/pi2-backup/"
|
||||||
|
|
||||||
# B. Script del Bot (tutti .py e .sh) -> services/telegram-bot
|
# B. Script del Bot (tutti .py e .sh) -> services/telegram-bot
|
||||||
@@ -61,32 +71,42 @@ fi
|
|||||||
echo "📡 Raccolta dinamica file remoti (Pi-1)..."
|
echo "📡 Raccolta dinamica file remoti (Pi-1)..."
|
||||||
|
|
||||||
# A. Script nella Home remota (.sh e .py) -> scripts/pi1-master
|
# A. Script nella Home remota (.sh e .py) -> scripts/pi1-master
|
||||||
# Nota: Richiede che le chiavi SSH siano configurate per non chiedere password
|
rsync -av -e "ssh -q" --include='*.sh' --include='*.py' --exclude='*' \
|
||||||
rsync -av -e "ssh -q" --include='*.sh' --include='*.py' --exclude='*' $PI1_USER@$PI1_IP:/home/daniely/ "$REPO_DIR/scripts/pi1-master/"
|
"$PI1_USER@$PI1_IP:/home/daniely/" "$REPO_DIR/scripts/pi1-master/" \
|
||||||
|
|| echo "⚠️ rsync Pi1 fallito (ignorato)"
|
||||||
|
|
||||||
# B. Recupero file specifici fuori dalla home (Legacy da vecchio script)
|
# B. Recupero file specifici fuori dalla home (Legacy da vecchio script)
|
||||||
# Se dhcp-alert.sh esiste ancora in /usr/local/bin, lo prendiamo
|
scp -q "$PI1_USER@$PI1_IP:/usr/local/bin/dhcp-alert.sh" ./scripts/pi1-master/ 2>/dev/null \
|
||||||
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)"
|
|| echo "⚠️ dhcp-alert.sh non trovato su Pi1 (ignorato)"
|
||||||
|
|
||||||
# C. Configurazione Keepalived Remota
|
# C. Configurazione Keepalived Remota
|
||||||
scp -q $PI1_USER@$PI1_IP:/etc/keepalived/keepalived.conf ./configs/keepalived_pi1.conf 2>/dev/null
|
if scp -q "$PI1_USER@$PI1_IP:/etc/keepalived/keepalived.conf" ./configs/keepalived_pi1.conf 2>/dev/null; then
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo "✅ Configurazione Keepalived Pi1 scaricata."
|
echo "✅ Configurazione Keepalived Pi1 scaricata."
|
||||||
else
|
else
|
||||||
echo "⚠️ Impossibile scaricare Keepalived conf da Pi1."
|
echo "⚠️ Impossibile scaricare Keepalived conf da Pi1."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# D. Sorgente Loogle MCP (app completa: qui vive solo su Pi1, fuori da git)
|
||||||
|
# .env resta escluso: contiene JWT secret e token Paperless/Gitea/OpenAI.
|
||||||
|
mkdir -p "$REPO_DIR/services/loogle-mcp"
|
||||||
|
rsync -av --delete -e "ssh -q" \
|
||||||
|
--exclude='.env' --exclude='data/' --exclude='__pycache__/' --exclude='*.pyc' --exclude='.cursor/' \
|
||||||
|
"$PI1_USER@$PI1_IP:/home/daniely/docker/loogle-mcp/" "$REPO_DIR/services/loogle-mcp/" \
|
||||||
|
|| echo "⚠️ rsync sorgente MCP fallito (ignorato)"
|
||||||
|
|
||||||
# 4. GIT PUSH
|
# 4. GIT PUSH
|
||||||
# -----------
|
# -----------
|
||||||
# Verifica se ci sono cambiamenti reali
|
if [[ -n $(git status --porcelain) ]]; then
|
||||||
if [[ `git status --porcelain` ]]; then
|
|
||||||
echo "📝 Rilevati cambiamenti. Eseguo Commit e Push..."
|
echo "📝 Rilevati cambiamenti. Eseguo Commit e Push..."
|
||||||
|
|
||||||
git add .
|
git add .
|
||||||
git commit -m "Backup automatico script del $DATE"
|
git commit -m "Backup automatico script del $DATE" || fail "Commit fallito"
|
||||||
git push -u origin main
|
|
||||||
|
|
||||||
echo "✅ Push completato con successo."
|
if git push origin main; then
|
||||||
|
echo "✅ Push completato con successo."
|
||||||
|
else
|
||||||
|
fail "Push su Gitea fallito (SSH/auth/Gitea down?)."
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "ℹ️ Nessun cambiamento rilevato. Repository già aggiornato."
|
echo "ℹ️ Nessun cambiamento rilevato. Repository già aggiornato."
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ PIHOLE_BIN="/usr/local/bin/pihole"
|
|||||||
REBOOT_DELAY_MIN=2
|
REBOOT_DELAY_MIN=2
|
||||||
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
|
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
|
||||||
WATCHTOWER_TIMEOUT=900
|
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)"
|
HOST_LABEL="$(hostname -s)"
|
||||||
REBOOT_ON_SUCCESS=true
|
REBOOT_ON_SUCCESS=true
|
||||||
@@ -218,6 +220,99 @@ run_watchtower() {
|
|||||||
fi
|
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() {
|
audit_apt() {
|
||||||
local holds
|
local holds
|
||||||
holds=$(apt-mark showhold 2>/dev/null || true)
|
holds=$(apt-mark showhold 2>/dev/null || true)
|
||||||
@@ -442,6 +537,8 @@ audit_eeprom
|
|||||||
|
|
||||||
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
|
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
|
||||||
run_watchtower
|
run_watchtower
|
||||||
|
# 4b. Immagini registry dei compose failover (Paperless, Vaultwarden, …) anche se non c'è container
|
||||||
|
pull_failover_standby_images
|
||||||
|
|
||||||
# 5. Audit residui
|
# 5. Audit residui
|
||||||
audit_docker
|
audit_docker
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Generato da .env.example — personalizza prima del deploy
|
||||||
|
MCP_BASE_URL=https://mcp.loogle.it
|
||||||
|
MCP_PORT=8700
|
||||||
|
MCP_JWT_SECRET='k9#mP2$vQ8!zX5_wL3*bF7%hR4&jN6+yT1-pC9@dG2#fK5^sV8~xL4?hZ1'
|
||||||
|
MCP_OAUTH_CLIENT_ID=loogle-mcp-public
|
||||||
|
|
||||||
|
PAPERLESS_URL=https://docs.loogle.it
|
||||||
|
# Token API utente (docs.loogle.it → Profilo → Token API) — vedi docs/PAPERLESS-TOKEN.md
|
||||||
|
# Opzionale fallback admin: PAPERLESS_API_TOKEN=
|
||||||
|
PAPERLESS_API_TOKEN_DANIELE=d75d378c1ce654578956eb595c0436cb96213b5c
|
||||||
|
PAPERLESS_API_TOKEN_LUCIA=b3124dab2d42f230e503adafaac7ff42551c1c56
|
||||||
|
PAPERLESS_API_TOKEN_DAVIDE=c50ecb5780eee62d5882bff980dbed8c7cf1b860
|
||||||
|
PAPERLESS_API_TOKEN_LUCA=45792e52a58d7f2a34fcf0f237b81770ac2f8fdc
|
||||||
|
|
||||||
|
# Gitea API — vedi docs/GITEA-TOKEN.md
|
||||||
|
GITEA_URL=https://git.loogle.it
|
||||||
|
GITEA_API_URL=http://192.168.128.81:3002
|
||||||
|
GITEA_API_TOKEN_DANIELE=86faeed42981e855762b34f86b53dc342766fa44
|
||||||
|
GITEA_API_TOKEN_LUCIA=478e0ac9436b412357fab144710ff74ab927e7fd
|
||||||
|
GITEA_API_TOKEN_DAVIDE=36a097519686ce64cc1103f66691555adbc3735d
|
||||||
|
GITEA_API_TOKEN_LUCA=051239ae1e8e60a89619249abed1b65550d29414
|
||||||
|
|
||||||
|
# Qdrant remoto su DS920 (consigliato) — lasciare vuoto per fallback SQLite su Pi
|
||||||
|
QDRANT_URL=http://192.168.128.100:6333
|
||||||
|
# Qdrant locale disabilitato su Pi5 (page size 16K) — vedi compose profile qdrant-local
|
||||||
|
OLLAMA_URL=http://192.168.128.100:11434
|
||||||
|
OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||||
|
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_EMBED_MODEL=text-embedding-3-small
|
||||||
|
INDEXER_INTERVAL_MINUTES=30
|
||||||
|
|
||||||
|
# Thermal gate DS920 — profilo lento/regolare (1 core Ollama + delay lunghi)
|
||||||
|
# Soft anticipato: un embed può alzare la temp di diversi °C
|
||||||
|
THERMAL_GATE_ENABLED=yes
|
||||||
|
THERMAL_TEMP_SOFT_C=58
|
||||||
|
THERMAL_TEMP_HARD_C=70
|
||||||
|
THERMAL_CPU_TARGET_PCT=40
|
||||||
|
THERMAL_POLL_S=30
|
||||||
|
THERMAL_RESUME_MARGIN_C=2
|
||||||
|
DS920_THERMAL_URL=http://192.168.128.100:9191/thermal
|
||||||
|
OLLAMA_NUM_THREAD=1
|
||||||
|
OLLAMA_EMBED_DELAY_S=10
|
||||||
|
OLLAMA_EMBED_COOL_DELAY_S=3
|
||||||
|
OLLAMA_KEEP_ALIVE_DEFAULT=120
|
||||||
|
OLLAMA_KEEP_ALIVE_COOL=300
|
||||||
|
# no = non scaricare il modello per HARD (container sempre su; solo pausa indexer)
|
||||||
|
OLLAMA_UNLOAD_ON_HARD=no
|
||||||
|
|
||||||
|
# RAG Gitea (P3)
|
||||||
|
GITEA_INDEX_ENABLED=yes
|
||||||
|
GITEA_INDEX_REPOS_DANIELE=daniele/rete,daniele/loogle-scripts
|
||||||
|
GITEA_INDEX_REPOS_LUCIA=lucia/nodus,lucia/progetti
|
||||||
|
GITEA_INDEX_REPOS_DAVIDE=davide/progetti
|
||||||
|
GITEA_INDEX_REPOS_LUCA=luca/progetti
|
||||||
|
GITEA_INDEX_MAX_FILES_PER_REPO=40
|
||||||
|
GITEA_INDEX_MAX_FILE_BYTES=120000
|
||||||
|
|
||||||
|
# P5 — Loogle Casa + Home Assistant (read-only)
|
||||||
|
LOOGLE_CASA_URL=https://casa.loogle.it
|
||||||
|
LOOGLE_CASA_API_URL=http://192.168.128.81:5602
|
||||||
|
LOOGLE_CASA_PASSWORD_DANIELE=@Dedelove1
|
||||||
|
LOOGLE_CASA_PASSWORD_LUCIA=lucia
|
||||||
|
LOOGLE_CASA_PASSWORD_DAVIDE=davide
|
||||||
|
LOOGLE_CASA_PASSWORD_LUCA=luca
|
||||||
|
|
||||||
|
HA_URL=https://ha.loogle.it
|
||||||
|
HA_API_URL=http://192.168.128.81:8123
|
||||||
|
HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI0ZGVhYzczOGYzMTA0Y2QzYTQ3YTViNDRlZTY2NjNhNiIsImlhdCI6MTc4MTE2NjE3NSwiZXhwIjoyMDk2NTI2MTc1fQ.2sou8f41sEcN_gn1NxJfb1MLNftMVHAsReQB0hP-jPo
|
||||||
|
|
||||||
|
# P6 — Irrigazione + Turni (read-only)
|
||||||
|
IRRIGAZIONE_URL=https://irri.loogle.it
|
||||||
|
IRRIGAZIONE_API_URL=http://192.168.128.81:5601
|
||||||
|
IRRIGAZIONE_PASSWORD_DANIELE=@Dedelove1
|
||||||
|
IRRIGAZIONE_PASSWORD_LUCIA=lucia
|
||||||
|
IRRIGAZIONE_PASSWORD_DAVIDE=dado
|
||||||
|
IRRIGAZIONE_PASSWORD_LUCA=luca
|
||||||
|
|
||||||
|
TURNI_URL=https://turni.loogle.it
|
||||||
|
TURNI_API_URL=https://turni.loogle.it
|
||||||
|
TURNI_PASSWORD_DANIELE=@Dedelove1
|
||||||
|
|
||||||
|
# P7 — RAG Irrigazione + Turni
|
||||||
|
APPS_INDEX_ENABLED=yes
|
||||||
|
APPS_INDEX_USER=daniele
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Loogle MCP Hub — copia in .env e personalizza
|
||||||
|
MCP_BASE_URL=https://mcp.loogle.it
|
||||||
|
MCP_PORT=8700
|
||||||
|
MCP_JWT_SECRET=change-me-to-a-long-random-string
|
||||||
|
MCP_OAUTH_CLIENT_ID=loogle-mcp-public
|
||||||
|
|
||||||
|
# Paperless API — vedi docs/PAPERLESS-TOKEN.md
|
||||||
|
PAPERLESS_URL=https://docs.loogle.it
|
||||||
|
# Fallback admin (opzionale se usi token per utente sotto)
|
||||||
|
PAPERLESS_API_TOKEN=
|
||||||
|
PAPERLESS_API_TOKEN_DANIELE=
|
||||||
|
PAPERLESS_API_TOKEN_LUCIA=
|
||||||
|
PAPERLESS_API_TOKEN_DAVIDE=
|
||||||
|
PAPERLESS_API_TOKEN_LUCA=
|
||||||
|
|
||||||
|
# Gitea API — vedi docs/GITEA-TOKEN.md
|
||||||
|
GITEA_URL=https://git.loogle.it
|
||||||
|
# Opzionale: URL API raggiungibile dal container MCP (es. http://192.168.128.81:3002)
|
||||||
|
# GITEA_API_URL=
|
||||||
|
GITEA_API_TOKEN=
|
||||||
|
GITEA_API_TOKEN_DANIELE=
|
||||||
|
GITEA_API_TOKEN_LUCIA=
|
||||||
|
GITEA_API_TOKEN_DAVIDE=
|
||||||
|
GITEA_API_TOKEN_LUCA=
|
||||||
|
|
||||||
|
# Qdrant (container locale o remoto)
|
||||||
|
QDRANT_URL=http://192.168.128.100:6333
|
||||||
|
|
||||||
|
# Embedding via Ollama su DS920+
|
||||||
|
OLLAMA_URL=http://192.168.128.100:11434
|
||||||
|
OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||||
|
|
||||||
|
# Fallback cloud embedding (opzionale)
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_EMBED_MODEL=text-embedding-3-small
|
||||||
|
|
||||||
|
# Indicizzazione
|
||||||
|
INDEXER_INTERVAL_MINUTES=30
|
||||||
|
|
||||||
|
# Thermal gate DS920 — evita overheating durante embedding
|
||||||
|
THERMAL_GATE_ENABLED=yes
|
||||||
|
THERMAL_TEMP_SOFT_C=58
|
||||||
|
THERMAL_TEMP_HARD_C=70
|
||||||
|
THERMAL_CPU_TARGET_PCT=40
|
||||||
|
THERMAL_POLL_S=30
|
||||||
|
THERMAL_RESUME_MARGIN_C=2
|
||||||
|
DS920_THERMAL_URL=http://192.168.128.100:9191/thermal
|
||||||
|
OLLAMA_NUM_THREAD=1
|
||||||
|
OLLAMA_EMBED_DELAY_S=10
|
||||||
|
OLLAMA_EMBED_COOL_DELAY_S=3
|
||||||
|
|
||||||
|
# RAG Gitea (P3) — indicizza markdown/codice da git.loogle.it
|
||||||
|
GITEA_INDEX_ENABLED=yes
|
||||||
|
# Opzionale: limita repo per utente (CSV). Vuoto = tutti i repo accessibili via API
|
||||||
|
# GITEA_INDEX_REPOS_DANIELE=daniele/rete,daniele/loogle-scripts
|
||||||
|
# GITEA_INDEX_REPOS_DAVIDE=davide/progetti
|
||||||
|
# GITEA_INDEX_REPOS_LUCA=luca/progetti
|
||||||
|
GITEA_INDEX_MAX_FILES_PER_REPO=150
|
||||||
|
GITEA_INDEX_MAX_FILE_BYTES=120000
|
||||||
|
|
||||||
|
# P5 — Loogle Casa + Home Assistant (read-only)
|
||||||
|
LOOGLE_CASA_URL=https://casa.loogle.it
|
||||||
|
# LOOGLE_CASA_API_URL=http://192.168.128.81:5602
|
||||||
|
LOOGLE_CASA_PASSWORD_DANIELE=
|
||||||
|
LOOGLE_CASA_PASSWORD_LUCIA=
|
||||||
|
LOOGLE_CASA_PASSWORD_DAVIDE=
|
||||||
|
LOOGLE_CASA_PASSWORD_LUCA=
|
||||||
|
HA_URL=https://ha.loogle.it
|
||||||
|
# HA_API_URL=http://192.168.128.81:8123
|
||||||
|
HA_TOKEN=
|
||||||
|
|
||||||
|
# P6 — Irrigazione + Turni (read-only)
|
||||||
|
IRRIGAZIONE_URL=https://irri.loogle.it
|
||||||
|
# IRRIGAZIONE_API_URL=http://192.168.128.81:5601
|
||||||
|
IRRIGAZIONE_PASSWORD_DANIELE=
|
||||||
|
TURNI_URL=https://turni.loogle.it
|
||||||
|
TURNI_PASSWORD_DANIELE=
|
||||||
|
|
||||||
|
# P7 — RAG Irrigazione + Turni
|
||||||
|
APPS_INDEX_ENABLED=yes
|
||||||
|
APPS_INDEX_USER=daniele
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
data/
|
||||||
|
.env
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 TZ=Europe/Rome
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl openssh-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /srv
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
COPY worker ./worker
|
||||||
|
COPY scripts ./scripts
|
||||||
|
COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
ENV PYTHONPATH=/srv
|
||||||
|
VOLUME /data
|
||||||
|
EXPOSE 8700
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8700"]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Loogle MCP Hub
|
||||||
|
|
||||||
|
Piattaforma MCP multi-utente per homelab LOOGLE.IT.
|
||||||
|
|
||||||
|
## Documentazione
|
||||||
|
|
||||||
|
| Documento | Per chi |
|
||||||
|
|-----------|---------|
|
||||||
|
| [ARCHITETTURA.md](docs/ARCHITETTURA.md) | Architettura, parametri, DNS, NPM, env |
|
||||||
|
| [GUIDA-ADMIN.md](docs/GUIDA-ADMIN.md) | Daniele — gestione e uso avanzato |
|
||||||
|
| [GUIDA-UTENTI.md](docs/GUIDA-UTENTI.md) | Lucia, Davide, Luca — client AI e uso quotidiano |
|
||||||
|
| [ONBOARDING.md](docs/ONBOARDING.md) | Dettaglio tecnico connector MCP |
|
||||||
|
|
||||||
|
## URL
|
||||||
|
|
||||||
|
- MCP: https://mcp.loogle.it/mcp
|
||||||
|
- Dashboard: https://mcp.loogle.it/dashboard
|
||||||
|
|
||||||
|
## Setup DNS + NPM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh
|
||||||
|
```
|
||||||
Whitespace-only changes.
@@ -0,0 +1,34 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Audit log for MCP tool invocations."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .db import get_conn
|
||||||
|
|
||||||
|
|
||||||
|
def log_tool(username: str, tool_name: str, resource_id: Optional[str] = None, detail: Optional[dict] = None) -> None:
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO audit_log(username,tool_name,resource_id,detail) VALUES (?,?,?,?)",
|
||||||
|
(username, tool_name, resource_id, json.dumps(detail or {}, ensure_ascii=False)),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM audit_log WHERE id NOT IN "
|
||||||
|
"(SELECT id FROM audit_log ORDER BY id DESC LIMIT 5000)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def list_audit(limit: int = 100, username: Optional[str] = None) -> list:
|
||||||
|
conn = get_conn()
|
||||||
|
if username:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM audit_log WHERE username=? ORDER BY id DESC LIMIT ?",
|
||||||
|
(username, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM audit_log ORDER BY id DESC LIMIT ?", (limit,)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Autenticazione utenti famiglia — pattern Loogle Casa."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
from .db import get_conn
|
||||||
|
|
||||||
|
SESSION_DAYS = 30
|
||||||
|
PBKDF2_ITER = 240_000
|
||||||
|
MAX_ATTEMPTS = 8
|
||||||
|
WINDOW_S = 600
|
||||||
|
_attempts: dict = {}
|
||||||
|
|
||||||
|
FAMILY_USERS = (
|
||||||
|
("daniele", True),
|
||||||
|
("lucia", False),
|
||||||
|
("davide", False),
|
||||||
|
("luca", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_SCOPES = (
|
||||||
|
"context:read context:write knowledge:read knowledge:write gitea:read gitea:write"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
salt = os.urandom(16)
|
||||||
|
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITER)
|
||||||
|
return "pbkdf2$%d$%s$%s" % (
|
||||||
|
PBKDF2_ITER,
|
||||||
|
base64.b64encode(salt).decode(),
|
||||||
|
base64.b64encode(dk).decode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored: str) -> bool:
|
||||||
|
try:
|
||||||
|
_, iters, salt_b64, dk_b64 = stored.split("$")
|
||||||
|
salt = base64.b64decode(salt_b64)
|
||||||
|
expected = base64.b64decode(dk_b64)
|
||||||
|
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, int(iters))
|
||||||
|
return hmac.compare_digest(dk, expected)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_family_users() -> None:
|
||||||
|
conn = get_conn()
|
||||||
|
for username, is_admin in FAMILY_USERS:
|
||||||
|
row = conn.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()
|
||||||
|
if row:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users(username,password_hash,is_admin,must_change_password)"
|
||||||
|
" VALUES (?,?,?,1)",
|
||||||
|
(username, hash_password(username), 1 if is_admin else 0),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def throttle(ip: str) -> None:
|
||||||
|
now = time.time()
|
||||||
|
hist = [t for t in _attempts.get(ip, []) if now - t < WINDOW_S]
|
||||||
|
_attempts[ip] = hist
|
||||||
|
if len(hist) >= MAX_ATTEMPTS:
|
||||||
|
raise HTTPException(429, "Troppi tentativi: riprova tra qualche minuto")
|
||||||
|
|
||||||
|
|
||||||
|
def record_attempt(ip: str) -> None:
|
||||||
|
_attempts.setdefault(ip, []).append(time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate(username: str, password: str) -> Optional[dict]:
|
||||||
|
conn = get_conn()
|
||||||
|
row = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone()
|
||||||
|
if not row or not verify_password(password, row["password_hash"]):
|
||||||
|
return None
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_id(user_id: int) -> Optional[dict]:
|
||||||
|
row = get_conn().execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_username(username: str) -> Optional[dict]:
|
||||||
|
row = get_conn().execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def change_password(user_id: int, old_password: str, new_password: str) -> bool:
|
||||||
|
row = get_conn().execute("SELECT password_hash FROM users WHERE id=?", (user_id,)).fetchone()
|
||||||
|
if not row or not verify_password(old_password, row["password_hash"]):
|
||||||
|
return False
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET password_hash=?, must_change_password=0 WHERE id=?",
|
||||||
|
(hash_password(new_password), user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def current_user_from_cookie(request: Request) -> dict:
|
||||||
|
token = request.cookies.get("mcp_session", "")
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(401, "Non autenticato")
|
||||||
|
row = get_conn().execute(
|
||||||
|
"SELECT u.id,u.username,u.is_admin,u.must_change_password"
|
||||||
|
" FROM sessions s JOIN users u ON u.id=s.user_id"
|
||||||
|
" WHERE s.token=? AND s.expires_at > datetime('now')",
|
||||||
|
(token,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(401, "Sessione scaduta")
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(request: Request) -> dict:
|
||||||
|
user = current_user_from_cookie(request)
|
||||||
|
if not user["is_admin"]:
|
||||||
|
raise HTTPException(403, "Riservato all'amministratore")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_sessions_table() -> None:
|
||||||
|
get_conn().execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
get_conn().commit()
|
||||||
Whitespace-only changes.
@@ -0,0 +1,154 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Collegamento progetti MCP ↔ repository Gitea."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..knowledge import gitea
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("loogle_mcp.context.gitea_link")
|
||||||
|
|
||||||
|
README_CANDIDATES = ("README.md", "readme.md", "Readme.md", "README.MD")
|
||||||
|
DOCS_DIR = "docs"
|
||||||
|
MAX_README_CHARS = 12_000
|
||||||
|
MAX_DOC_FILES = 25
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_gitea_repo(repo: Optional[str]) -> Optional[str]:
|
||||||
|
if repo is None:
|
||||||
|
return None
|
||||||
|
cleaned = repo.strip()
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
owner, name = gitea.parse_repo(cleaned)
|
||||||
|
return f"{owner}/{name}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_repo_access(username: str, repo: str) -> None:
|
||||||
|
owner, name = gitea.parse_repo(repo)
|
||||||
|
gitea._request("GET", f"/repos/{owner}/{name}", username=username)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_readme(username: str, repo: str) -> Optional[dict]:
|
||||||
|
for path in README_CANDIDATES:
|
||||||
|
try:
|
||||||
|
data = gitea.get_file(repo, path, username=username)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("README %s/%s non leggibile: %s", repo, path, exc)
|
||||||
|
continue
|
||||||
|
if data.get("type") != "file":
|
||||||
|
continue
|
||||||
|
content = (data.get("content") or "").strip()
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
truncated = len(content) > MAX_README_CHARS
|
||||||
|
return {
|
||||||
|
"path": path,
|
||||||
|
"sha": data.get("sha"),
|
||||||
|
"html_url": data.get("html_url"),
|
||||||
|
"content": content[:MAX_README_CHARS],
|
||||||
|
"truncated": truncated,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_docs_index(username: str, repo: str) -> list[dict]:
|
||||||
|
try:
|
||||||
|
data = gitea.get_file(repo, DOCS_DIR, username=username)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return []
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Directory docs/ non leggibile per %s: %s", repo, exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if data.get("type") != "dir":
|
||||||
|
return []
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for item in data.get("entries") or []:
|
||||||
|
if item.get("type") != "file":
|
||||||
|
continue
|
||||||
|
path = item.get("path") or ""
|
||||||
|
if not path.lower().endswith((".md", ".txt", ".rst")):
|
||||||
|
continue
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"path": path,
|
||||||
|
"size": item.get("size"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(entries) >= MAX_DOC_FILES:
|
||||||
|
break
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def project_enrichment(username: str, gitea_repo: str) -> dict:
|
||||||
|
repo = normalize_gitea_repo(gitea_repo)
|
||||||
|
if not repo:
|
||||||
|
return {"linked": False}
|
||||||
|
|
||||||
|
base = {
|
||||||
|
"linked": True,
|
||||||
|
"repo": repo,
|
||||||
|
"html_url": f"{gitea.public_base_url()}/{repo}",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not gitea.is_configured(username):
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"available": False,
|
||||||
|
"error": "Gitea non configurato per questo utente",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
verify_repo_access(username, repo)
|
||||||
|
readme = fetch_readme(username, repo)
|
||||||
|
docs = fetch_docs_index(username, repo)
|
||||||
|
open_issues = None
|
||||||
|
try:
|
||||||
|
issues = gitea.list_issues(repo, state="open", limit=1, username=username)
|
||||||
|
open_issues = issues.get("count", 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"available": True,
|
||||||
|
"readme": readme,
|
||||||
|
"docs_files": docs,
|
||||||
|
"open_issues": open_issues,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"available": False,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def readme_seed_block(repo: str, readme: dict) -> str:
|
||||||
|
path = readme.get("path") or "README.md"
|
||||||
|
content = readme.get("content") or ""
|
||||||
|
truncated_note = "\n\n*(README troncato — usa get_file per il testo completo)*" if readme.get("truncated") else ""
|
||||||
|
return (
|
||||||
|
f"<!-- seed:gitea {repo} {path} -->\n\n"
|
||||||
|
f"## Sorgente Gitea: `{repo}`\n\n"
|
||||||
|
f"Contenuto iniziale da `{path}`.\n\n"
|
||||||
|
f"{content.rstrip()}{truncated_note}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_context_from_readme(username: str, repo: str, context_md: str) -> tuple[str, bool]:
|
||||||
|
"""Importa README nel context se non già presente un seed Gitea."""
|
||||||
|
if "<!-- seed:gitea " in context_md:
|
||||||
|
return context_md, False
|
||||||
|
readme = fetch_readme(username, repo)
|
||||||
|
if not readme:
|
||||||
|
return context_md, False
|
||||||
|
block = readme_seed_block(repo, readme)
|
||||||
|
if context_md.strip():
|
||||||
|
return context_md.rstrip() + "\n\n---\n\n" + block, True
|
||||||
|
return block, True
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Filesystem-backed project context store."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from . import gitea_link
|
||||||
|
from ..knowledge import gitea
|
||||||
|
|
||||||
|
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _context_root() -> str:
|
||||||
|
return os.environ.get("MCP_CONTEXT_ROOT", "/data/context")
|
||||||
|
|
||||||
|
|
||||||
|
def _user_root(username: str) -> str:
|
||||||
|
path = os.path.join(_context_root(), username, "projects")
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _project_dir(username: str, project_id: str) -> str:
|
||||||
|
if not SLUG_RE.match(project_id):
|
||||||
|
raise ValueError("ID progetto non valido")
|
||||||
|
return os.path.join(_user_root(username), project_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_meta(username: str, project_id: str) -> tuple[str, dict]:
|
||||||
|
proj_dir = _project_dir(username, project_id)
|
||||||
|
meta_path = os.path.join(proj_dir, "meta.json")
|
||||||
|
if not os.path.isfile(meta_path):
|
||||||
|
raise FileNotFoundError("Progetto non trovato")
|
||||||
|
meta = json.load(open(meta_path, encoding="utf-8"))
|
||||||
|
return meta_path, meta
|
||||||
|
|
||||||
|
|
||||||
|
def _save_meta(meta_path: str, meta: dict) -> None:
|
||||||
|
meta["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
with open(meta_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_gitea_link(
|
||||||
|
username: str,
|
||||||
|
meta: dict,
|
||||||
|
gitea_repo: Optional[str],
|
||||||
|
*,
|
||||||
|
verify: bool = True,
|
||||||
|
seed_from_gitea: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
repo = gitea_link.normalize_gitea_repo(gitea_repo)
|
||||||
|
if verify and repo:
|
||||||
|
if not gitea.is_configured(username):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Gitea non configurato per questo utente — impossibile collegare il repository"
|
||||||
|
)
|
||||||
|
gitea_link.verify_repo_access(username, repo)
|
||||||
|
|
||||||
|
if repo:
|
||||||
|
meta["gitea_repo"] = repo
|
||||||
|
else:
|
||||||
|
meta.pop("gitea_repo", None)
|
||||||
|
|
||||||
|
if seed_from_gitea and repo:
|
||||||
|
proj_dir = _project_dir(username, meta["id"])
|
||||||
|
ctx_path = os.path.join(proj_dir, "context.md")
|
||||||
|
context_md = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
|
||||||
|
new_md, changed = gitea_link.seed_context_from_readme(username, repo, context_md)
|
||||||
|
if changed:
|
||||||
|
with open(ctx_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(new_md)
|
||||||
|
meta["gitea_seeded_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def list_projects(username: str, include_archived: bool = False) -> list:
|
||||||
|
root = _user_root(username)
|
||||||
|
projects = []
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
return projects
|
||||||
|
for name in sorted(os.listdir(root)):
|
||||||
|
meta_path = os.path.join(root, name, "meta.json")
|
||||||
|
if not os.path.isfile(meta_path):
|
||||||
|
continue
|
||||||
|
meta = json.load(open(meta_path, encoding="utf-8"))
|
||||||
|
if meta.get("archived") and not include_archived:
|
||||||
|
continue
|
||||||
|
projects.append(meta)
|
||||||
|
return projects
|
||||||
|
|
||||||
|
|
||||||
|
def create_project(
|
||||||
|
username: str,
|
||||||
|
title: str,
|
||||||
|
tags: Optional[list[str]] = None,
|
||||||
|
gitea_repo: Optional[str] = None,
|
||||||
|
seed_from_gitea: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
slug_base = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "progetto"
|
||||||
|
project_id = slug_base[:40]
|
||||||
|
root = _user_root(username)
|
||||||
|
while os.path.exists(os.path.join(root, project_id)):
|
||||||
|
project_id = f"{slug_base[:32]}-{secrets.token_hex(2)}"
|
||||||
|
proj_dir = _project_dir(username, project_id)
|
||||||
|
os.makedirs(os.path.join(proj_dir, "sessions"), exist_ok=True)
|
||||||
|
os.makedirs(os.path.join(proj_dir, "artifacts"), exist_ok=True)
|
||||||
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
meta = {
|
||||||
|
"id": project_id,
|
||||||
|
"title": title.strip(),
|
||||||
|
"tags": tags or [],
|
||||||
|
"status": "active",
|
||||||
|
"archived": False,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
repo = gitea_link.normalize_gitea_repo(gitea_repo)
|
||||||
|
if repo:
|
||||||
|
meta["gitea_repo"] = repo
|
||||||
|
|
||||||
|
with open(os.path.join(proj_dir, "meta.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||||
|
with open(os.path.join(proj_dir, "context.md"), "w", encoding="utf-8") as f:
|
||||||
|
f.write(f"# {title.strip()}\n\n")
|
||||||
|
|
||||||
|
if repo:
|
||||||
|
meta = _apply_gitea_link(
|
||||||
|
username,
|
||||||
|
meta,
|
||||||
|
repo,
|
||||||
|
verify=True,
|
||||||
|
seed_from_gitea=seed_from_gitea,
|
||||||
|
)
|
||||||
|
_save_meta(os.path.join(proj_dir, "meta.json"), meta)
|
||||||
|
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def link_project_repo(
|
||||||
|
username: str,
|
||||||
|
project_id: str,
|
||||||
|
gitea_repo: Optional[str] = None,
|
||||||
|
seed_from_gitea: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
meta_path, meta = _load_meta(username, project_id)
|
||||||
|
meta = _apply_gitea_link(
|
||||||
|
username,
|
||||||
|
meta,
|
||||||
|
gitea_repo,
|
||||||
|
verify=bool(gitea_link.normalize_gitea_repo(gitea_repo)),
|
||||||
|
seed_from_gitea=seed_from_gitea,
|
||||||
|
)
|
||||||
|
_save_meta(meta_path, meta)
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_context(
|
||||||
|
username: str,
|
||||||
|
project_id: str,
|
||||||
|
session_limit: int = 5,
|
||||||
|
include_gitea: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
proj_dir = _project_dir(username, project_id)
|
||||||
|
meta_path = os.path.join(proj_dir, "meta.json")
|
||||||
|
if not os.path.isfile(meta_path):
|
||||||
|
raise FileNotFoundError("Progetto non trovato")
|
||||||
|
meta = json.load(open(meta_path, encoding="utf-8"))
|
||||||
|
ctx_path = os.path.join(proj_dir, "context.md")
|
||||||
|
context_md = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
|
||||||
|
sessions_dir = os.path.join(proj_dir, "sessions")
|
||||||
|
sessions = []
|
||||||
|
if os.path.isdir(sessions_dir):
|
||||||
|
files = sorted(os.listdir(sessions_dir), reverse=True)[:session_limit]
|
||||||
|
for fname in files:
|
||||||
|
path = os.path.join(sessions_dir, fname)
|
||||||
|
if os.path.isfile(path):
|
||||||
|
sessions.append({"name": fname, "content": open(path, encoding="utf-8").read()})
|
||||||
|
|
||||||
|
result = {"meta": meta, "context_md": context_md, "recent_sessions": sessions}
|
||||||
|
gitea_repo = meta.get("gitea_repo")
|
||||||
|
if include_gitea and gitea_repo:
|
||||||
|
result["gitea"] = gitea_link.project_enrichment(username, gitea_repo)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def save_context(username: str, project_id: str, content: str, mode: str = "append") -> dict:
|
||||||
|
proj_dir = _project_dir(username, project_id)
|
||||||
|
meta_path = os.path.join(proj_dir, "meta.json")
|
||||||
|
if not os.path.isfile(meta_path):
|
||||||
|
raise FileNotFoundError("Progetto non trovato")
|
||||||
|
ctx_path = os.path.join(proj_dir, "context.md")
|
||||||
|
if mode == "replace":
|
||||||
|
text = content
|
||||||
|
else:
|
||||||
|
existing = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
|
||||||
|
text = existing.rstrip() + "\n\n" + content.strip() + "\n"
|
||||||
|
with open(ctx_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
meta = json.load(open(meta_path, encoding="utf-8"))
|
||||||
|
_save_meta(meta_path, meta)
|
||||||
|
snapshot = datetime.now().strftime("%Y%m%d-%H%M%S") + ".md"
|
||||||
|
with open(os.path.join(proj_dir, "sessions", snapshot), "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def archive_project(username: str, project_id: str, archived: bool = True) -> dict:
|
||||||
|
meta_path, meta = _load_meta(username, project_id)
|
||||||
|
meta["archived"] = archived
|
||||||
|
meta["status"] = "archived" if archived else "active"
|
||||||
|
_save_meta(meta_path, meta)
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def list_resources(username: str) -> list:
|
||||||
|
resources = []
|
||||||
|
for meta in list_projects(username, include_archived=False):
|
||||||
|
desc = f"Contesto progetto {meta['id']}"
|
||||||
|
if meta.get("gitea_repo"):
|
||||||
|
desc += f" (Gitea: {meta['gitea_repo']})"
|
||||||
|
resources.append(
|
||||||
|
{
|
||||||
|
"uri": f"loogle://context/{username}/{meta['id']}",
|
||||||
|
"name": meta["title"],
|
||||||
|
"description": desc,
|
||||||
|
"mimeType": "text/markdown",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return resources
|
||||||
|
|
||||||
|
|
||||||
|
def read_resource(username: str, uri: str) -> dict:
|
||||||
|
prefix = f"loogle://context/{username}/"
|
||||||
|
if not uri.startswith(prefix):
|
||||||
|
raise FileNotFoundError("Risorsa non trovata")
|
||||||
|
project_id = uri[len(prefix):]
|
||||||
|
data = get_project_context(username, project_id, session_limit=0, include_gitea=False)
|
||||||
|
text = data["context_md"]
|
||||||
|
gitea_repo = data["meta"].get("gitea_repo")
|
||||||
|
if gitea_repo:
|
||||||
|
text = f"<!-- gitea_repo: {gitea_repo} -->\n\n" + text
|
||||||
|
return {
|
||||||
|
"uri": uri,
|
||||||
|
"mimeType": "text/markdown",
|
||||||
|
"text": text,
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""SQLite schema for Loogle MCP Hub."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("MCP_DB", "/data/loogle_mcp.db")
|
||||||
|
_local = threading.local()
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||||
|
client_id TEXT PRIMARY KEY,
|
||||||
|
client_name TEXT NOT NULL,
|
||||||
|
redirect_uris TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_codes (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
code_challenge TEXT,
|
||||||
|
code_challenge_method TEXT,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
revoked INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS revoked_jtis (
|
||||||
|
jti TEXT PRIMARY KEY,
|
||||||
|
revoked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
tool_name TEXT NOT NULL,
|
||||||
|
resource_id TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS indexed_documents (
|
||||||
|
doc_id INTEGER PRIMARY KEY,
|
||||||
|
title TEXT,
|
||||||
|
owner TEXT,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'family',
|
||||||
|
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
chunk_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS indexed_gitea_files (
|
||||||
|
repo TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
sha TEXT,
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'family',
|
||||||
|
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (repo, path)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gitea_indexed_repo ON indexed_gitea_files(repo);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gitea_indexed_at ON indexed_gitea_files(indexed_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS indexed_apps_records (
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
record_id TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
owner TEXT NOT NULL DEFAULT 'family',
|
||||||
|
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (source, record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_apps_indexed_source ON indexed_apps_records(source);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_apps_indexed_at ON indexed_apps_records(indexed_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_at);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn() -> sqlite3.Connection:
|
||||||
|
conn = getattr(_local, "conn", None)
|
||||||
|
if conn is None:
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
_local.conn = conn
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
get_conn().executescript(SCHEMA)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Client REST verso app homelab LOOGLE."""
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Client Loogle Casa — dashboard, meteo, rete."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from .session_client import SessionApiClient
|
||||||
|
|
||||||
|
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||||
|
# Daniele MCP → admin su Loogle Casa
|
||||||
|
MCP_TO_SERVICE_USER = {
|
||||||
|
"daniele": "admin",
|
||||||
|
"lucia": "lucia",
|
||||||
|
"davide": "davide",
|
||||||
|
"luca": "luca",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _service_username(mcp_username: str) -> str:
|
||||||
|
return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower())
|
||||||
|
PUBLIC_URL = os.environ.get("LOOGLE_CASA_URL", "https://casa.loogle.it").rstrip("/")
|
||||||
|
API_URL = os.environ.get("LOOGLE_CASA_API_URL", PUBLIC_URL).rstrip("/")
|
||||||
|
|
||||||
|
_client: Optional[SessionApiClient] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _client_instance() -> SessionApiClient:
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
verify = os.environ.get("LOOGLE_CASA_VERIFY_SSL", "true").strip().lower() not in (
|
||||||
|
"0", "false", "no", "off",
|
||||||
|
)
|
||||||
|
client = SessionApiClient(
|
||||||
|
service="LOOGLE_CASA",
|
||||||
|
base_url=API_URL,
|
||||||
|
verify_ssl=verify,
|
||||||
|
)
|
||||||
|
client.map_username = _service_username # type: ignore[attr-defined]
|
||||||
|
_client = client
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured(username: Optional[str] = None) -> bool:
|
||||||
|
user = (username or "daniele").lower()
|
||||||
|
if user in MCP_USERS:
|
||||||
|
key = f"LOOGLE_CASA_PASSWORD_{user.upper()}"
|
||||||
|
if os.environ.get(key, "").strip():
|
||||||
|
return True
|
||||||
|
if os.environ.get("LOOGLE_CASA_PASSWORD", "").strip():
|
||||||
|
return True
|
||||||
|
return user in MCP_USERS
|
||||||
|
|
||||||
|
|
||||||
|
def get_dashboard(username: str) -> dict:
|
||||||
|
return _client_instance().get("/api/dashboard", username=username)
|
||||||
|
|
||||||
|
|
||||||
|
def get_weather_home(username: str) -> dict:
|
||||||
|
return _client_instance().get("/api/weather/home", username=username)
|
||||||
|
|
||||||
|
|
||||||
|
def get_network_overview(username: str) -> dict:
|
||||||
|
return _client_instance().get("/api/network/overview", username=username)
|
||||||
|
|
||||||
|
|
||||||
|
def get_network_failover_status(username: str) -> dict:
|
||||||
|
return _client_instance().get("/api/network/failover/status", username=username)
|
||||||
|
|
||||||
|
|
||||||
|
def get_network_mcp_status(username: str) -> dict:
|
||||||
|
return _client_instance().get("/api/network/mcp", username=username)
|
||||||
|
|
||||||
|
|
||||||
|
def get_alerts_cards(username: str) -> Any:
|
||||||
|
return _client_instance().get("/api/alerts/cards", username=username)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Client Home Assistant REST API (read-only)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
PUBLIC_URL = os.environ.get("HA_URL", "https://ha.loogle.it").rstrip("/")
|
||||||
|
API_URL = os.environ.get("HA_API_URL", PUBLIC_URL).rstrip("/")
|
||||||
|
HA_TOKEN = os.environ.get("HA_TOKEN", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
return bool(HA_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict:
|
||||||
|
if not HA_TOKEN:
|
||||||
|
raise RuntimeError(
|
||||||
|
"HA_TOKEN non configurato in .env — crea un long-lived token in Home Assistant"
|
||||||
|
)
|
||||||
|
return {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def _request(method: str, path: str, *, params: Optional[dict] = None) -> Any:
|
||||||
|
verify = os.environ.get("HA_VERIFY_SSL", "true").strip().lower() not in (
|
||||||
|
"0", "false", "no", "off",
|
||||||
|
)
|
||||||
|
url = path if path.startswith("http") else urljoin(API_URL + "/", path.lstrip("/"))
|
||||||
|
with httpx.Client(timeout=30.0, verify=verify) as client:
|
||||||
|
resp = client.request(method, url, headers=_headers(), params=params)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise RuntimeError(f"Home Assistant {path}: HTTP {resp.status_code} {resp.text[:200]}")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_config() -> dict:
|
||||||
|
return _request("GET", "/api/config")
|
||||||
|
|
||||||
|
|
||||||
|
def get_entity(entity_id: str) -> dict:
|
||||||
|
return _request("GET", f"/api/states/{entity_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def list_entities(domain: Optional[str] = None, limit: int = 100) -> list:
|
||||||
|
states = _request("GET", "/api/states")
|
||||||
|
if domain:
|
||||||
|
prefix = domain if domain.endswith(".") else f"{domain}."
|
||||||
|
states = [s for s in states if s.get("entity_id", "").startswith(prefix)]
|
||||||
|
return states[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def search_entities(query: str, limit: int = 30) -> list:
|
||||||
|
q = query.lower()
|
||||||
|
matches = []
|
||||||
|
for state in _request("GET", "/api/states"):
|
||||||
|
eid = state.get("entity_id", "")
|
||||||
|
name = (state.get("attributes") or {}).get("friendly_name", "")
|
||||||
|
blob = f"{eid} {name}".lower()
|
||||||
|
if q in blob:
|
||||||
|
matches.append({
|
||||||
|
"entity_id": eid,
|
||||||
|
"state": state.get("state"),
|
||||||
|
"friendly_name": name,
|
||||||
|
"last_changed": state.get("last_changed"),
|
||||||
|
})
|
||||||
|
if len(matches) >= limit:
|
||||||
|
break
|
||||||
|
return matches
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Client Irrigazione Smart — irri.loogle.it."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from .session_client import SessionApiClient
|
||||||
|
|
||||||
|
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||||
|
MCP_TO_SERVICE_USER = {
|
||||||
|
"daniele": "admin",
|
||||||
|
"lucia": "lucia",
|
||||||
|
"davide": "dado",
|
||||||
|
"luca": "luca",
|
||||||
|
}
|
||||||
|
|
||||||
|
PUBLIC_URL = os.environ.get("IRRIGAZIONE_URL", "https://irri.loogle.it").rstrip("/")
|
||||||
|
API_URL = os.environ.get("IRRIGAZIONE_API_URL", PUBLIC_URL).rstrip("/")
|
||||||
|
|
||||||
|
_client: Optional[SessionApiClient] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _service_username(mcp_username: str) -> str:
|
||||||
|
return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _client_instance() -> SessionApiClient:
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
verify = os.environ.get("IRRIGAZIONE_VERIFY_SSL", "true").strip().lower() not in (
|
||||||
|
"0", "false", "no", "off",
|
||||||
|
)
|
||||||
|
client = SessionApiClient(
|
||||||
|
service="IRRIGAZIONE",
|
||||||
|
base_url=API_URL,
|
||||||
|
verify_ssl=verify,
|
||||||
|
)
|
||||||
|
client.map_username = _service_username # type: ignore[attr-defined]
|
||||||
|
_client = client
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured(username: Optional[str] = None) -> bool:
|
||||||
|
user = (username or "daniele").lower()
|
||||||
|
if os.environ.get(f"IRRIGAZIONE_PASSWORD_{user.upper()}", "").strip():
|
||||||
|
return True
|
||||||
|
if os.environ.get("IRRIGAZIONE_PASSWORD", "").strip():
|
||||||
|
return True
|
||||||
|
return user in MCP_USERS
|
||||||
|
|
||||||
|
|
||||||
|
def get_status(username: str) -> dict:
|
||||||
|
return _client_instance().get("/api/status", username=username)
|
||||||
|
|
||||||
|
|
||||||
|
def get_zones(username: str) -> Any:
|
||||||
|
return _client_instance().get("/api/zones", username=username)
|
||||||
|
|
||||||
|
|
||||||
|
def get_history(username: str, limit: int = 30) -> Any:
|
||||||
|
data = _client_instance().get("/api/history", username=username)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data[:limit]
|
||||||
|
if isinstance(data, dict) and "items" in data:
|
||||||
|
items = data["items"]
|
||||||
|
return items[:limit] if isinstance(items, list) else data
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_events(username: str, limit: int = 50) -> Any:
|
||||||
|
data = _client_instance().get("/api/events", username=username)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data[:limit]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_lavori_summary(username: str) -> Any:
|
||||||
|
return _client_instance().get("/api/lavori/summary", username=username)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Client HTTP con sessione cookie (Loogle Casa, Irrigazione)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("loogle_mcp.session_client")
|
||||||
|
|
||||||
|
_sessions: dict[str, tuple[str, float]] = {}
|
||||||
|
_sessions_lock = threading.Lock()
|
||||||
|
SESSION_TTL = 3600 * 12
|
||||||
|
|
||||||
|
|
||||||
|
class SessionApiClient:
|
||||||
|
"""Login cookie-based con cache per utente MCP."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
service: str,
|
||||||
|
base_url: str,
|
||||||
|
login_path: str = "/api/login",
|
||||||
|
verify_ssl: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.service = service
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.login_path = login_path
|
||||||
|
self.verify_ssl = verify_ssl
|
||||||
|
|
||||||
|
def _password_for_user(self, username: str) -> Optional[str]:
|
||||||
|
import os
|
||||||
|
user = username.lower()
|
||||||
|
env_key = f"{self.service}_PASSWORD_{user.upper()}"
|
||||||
|
pwd = os.environ.get(env_key, "").strip()
|
||||||
|
if pwd:
|
||||||
|
return pwd
|
||||||
|
fallback = os.environ.get(f"{self.service}_PASSWORD", "").strip()
|
||||||
|
if fallback:
|
||||||
|
return fallback
|
||||||
|
return user
|
||||||
|
|
||||||
|
def _cache_key(self, username: str) -> str:
|
||||||
|
return f"{self.service}:{username.lower()}"
|
||||||
|
|
||||||
|
def _get_cached_cookie(self, username: str) -> Optional[str]:
|
||||||
|
key = self._cache_key(username)
|
||||||
|
with _sessions_lock:
|
||||||
|
row = _sessions.get(key)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
cookie, expires = row
|
||||||
|
if time.time() > expires:
|
||||||
|
_sessions.pop(key, None)
|
||||||
|
return None
|
||||||
|
return cookie
|
||||||
|
|
||||||
|
def _store_cookie(self, username: str, cookie: str) -> None:
|
||||||
|
key = self._cache_key(username)
|
||||||
|
with _sessions_lock:
|
||||||
|
_sessions[key] = (cookie, time.time() + SESSION_TTL)
|
||||||
|
|
||||||
|
def login(self, username: str) -> str:
|
||||||
|
cached = self._get_cached_cookie(username)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
service_user = username
|
||||||
|
if hasattr(self, "map_username"):
|
||||||
|
service_user = self.map_username(username) # type: ignore[attr-defined]
|
||||||
|
password = self._password_for_user(username)
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Password {self.service} non configurata per {username}. "
|
||||||
|
f"Imposta {self.service}_PASSWORD_{username.upper()} in .env"
|
||||||
|
)
|
||||||
|
url = urljoin(self.base_url + "/", self.login_path.lstrip("/"))
|
||||||
|
with httpx.Client(timeout=30.0, verify=self.verify_ssl) as client:
|
||||||
|
resp = client.post(
|
||||||
|
url, json={"username": service_user, "password": password},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Login {self.service} fallito per {username}: HTTP {resp.status_code}"
|
||||||
|
)
|
||||||
|
cookie = resp.cookies.get("session")
|
||||||
|
if not cookie:
|
||||||
|
raise RuntimeError(f"Login {self.service}: cookie session mancante")
|
||||||
|
self._store_cookie(username, cookie)
|
||||||
|
return cookie
|
||||||
|
|
||||||
|
def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
params: Optional[dict] = None,
|
||||||
|
json_body: Optional[dict] = None,
|
||||||
|
) -> Any:
|
||||||
|
cookie = self.login(username)
|
||||||
|
url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/"))
|
||||||
|
headers = {"Cookie": f"session={cookie}"}
|
||||||
|
with httpx.Client(timeout=60.0, verify=self.verify_ssl) as client:
|
||||||
|
resp = client.request(method, url, headers=headers, params=params, json=json_body)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
with _sessions_lock:
|
||||||
|
_sessions.pop(self._cache_key(username), None)
|
||||||
|
cookie = self.login(username)
|
||||||
|
headers = {"Cookie": f"session={cookie}"}
|
||||||
|
resp = client.request(method, url, headers=headers, params=params, json=json_body)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise RuntimeError(f"{self.service} {method} {path}: HTTP {resp.status_code} {resp.text[:200]}")
|
||||||
|
if resp.headers.get("content-type", "").startswith("application/json"):
|
||||||
|
return resp.json()
|
||||||
|
return resp.text
|
||||||
|
|
||||||
|
def get(self, path: str, *, username: str, params: Optional[dict] = None) -> Any:
|
||||||
|
return self.request("GET", path, username=username, params=params)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Client Turni-Live — turni.loogle.it (JWT Bearer)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("loogle_mcp.turni")
|
||||||
|
|
||||||
|
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||||
|
MCP_TO_SERVICE_USER = {
|
||||||
|
"daniele": "daniely",
|
||||||
|
"lucia": "lucia",
|
||||||
|
"davide": "davide",
|
||||||
|
"luca": "luca",
|
||||||
|
}
|
||||||
|
|
||||||
|
PUBLIC_URL = os.environ.get("TURNI_URL", "https://turni.loogle.it").rstrip("/")
|
||||||
|
API_URL = os.environ.get("TURNI_API_URL", PUBLIC_URL).rstrip("/")
|
||||||
|
|
||||||
|
_jwt_cache: dict[str, tuple[str, float]] = {}
|
||||||
|
_jwt_lock = threading.Lock()
|
||||||
|
JWT_TTL = 3600 * 6
|
||||||
|
|
||||||
|
|
||||||
|
def _service_username(mcp_username: str) -> str:
|
||||||
|
return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _password_for_user(mcp_username: str) -> Optional[str]:
|
||||||
|
user = mcp_username.lower()
|
||||||
|
pwd = os.environ.get(f"TURNI_PASSWORD_{user.upper()}", "").strip()
|
||||||
|
if pwd:
|
||||||
|
return pwd
|
||||||
|
return os.environ.get("TURNI_PASSWORD", "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def _jwt_for_user(mcp_username: str) -> Optional[str]:
|
||||||
|
user = mcp_username.lower()
|
||||||
|
direct = os.environ.get(f"TURNI_JWT_{user.upper()}", "").strip()
|
||||||
|
if direct:
|
||||||
|
return direct
|
||||||
|
return os.environ.get("TURNI_JWT", "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured(username: Optional[str] = None) -> bool:
|
||||||
|
user = (username or "daniele").lower()
|
||||||
|
if _jwt_for_user(user):
|
||||||
|
return True
|
||||||
|
if _password_for_user(user):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _store_jwt(mcp_username: str, token: str) -> None:
|
||||||
|
with _jwt_lock:
|
||||||
|
_jwt_cache[mcp_username.lower()] = (token, time.time() + JWT_TTL)
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_jwt(mcp_username: str) -> Optional[str]:
|
||||||
|
with _jwt_lock:
|
||||||
|
row = _jwt_cache.get(mcp_username.lower())
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
token, expires = row
|
||||||
|
if time.time() > expires:
|
||||||
|
_jwt_cache.pop(mcp_username.lower(), None)
|
||||||
|
return None
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def login(mcp_username: str) -> str:
|
||||||
|
cached = _cached_jwt(mcp_username)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
preset = _jwt_for_user(mcp_username)
|
||||||
|
if preset:
|
||||||
|
_store_jwt(mcp_username, preset)
|
||||||
|
return preset
|
||||||
|
password = _password_for_user(mcp_username)
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Turni non configurato per {mcp_username}. "
|
||||||
|
f"Imposta TURNI_PASSWORD_{mcp_username.upper()} o TURNI_JWT_{mcp_username.upper()}"
|
||||||
|
)
|
||||||
|
service_user = _service_username(mcp_username)
|
||||||
|
verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in (
|
||||||
|
"0", "false", "no", "off",
|
||||||
|
)
|
||||||
|
url = urljoin(API_URL + "/", "api/auth/login")
|
||||||
|
with httpx.Client(timeout=30.0, verify=verify) as client:
|
||||||
|
resp = client.post(url, json={"username": service_user, "password": password})
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"Login Turni fallito: HTTP {resp.status_code}")
|
||||||
|
data = resp.json()
|
||||||
|
token = data.get("token")
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("Login Turni: token JWT mancante")
|
||||||
|
_store_jwt(mcp_username, token)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
mcp_username: str,
|
||||||
|
params: Optional[dict] = None,
|
||||||
|
) -> Any:
|
||||||
|
token = login(mcp_username)
|
||||||
|
verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in (
|
||||||
|
"0", "false", "no", "off",
|
||||||
|
)
|
||||||
|
url = path if path.startswith("http") else urljoin(API_URL + "/", path.lstrip("/"))
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
with httpx.Client(timeout=60.0, verify=verify) as client:
|
||||||
|
resp = client.request(method, url, headers=headers, params=params)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
with _jwt_lock:
|
||||||
|
_jwt_cache.pop(mcp_username.lower(), None)
|
||||||
|
headers["Authorization"] = f"Bearer {login(mcp_username)}"
|
||||||
|
resp = client.request(method, url, headers=headers, params=params)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise RuntimeError(f"Turni {path}: HTTP {resp.status_code} {resp.text[:200]}")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_status() -> dict:
|
||||||
|
verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in (
|
||||||
|
"0", "false", "no", "off",
|
||||||
|
)
|
||||||
|
url = urljoin(API_URL + "/", "api/status")
|
||||||
|
with httpx.Client(timeout=30.0, verify=verify) as client:
|
||||||
|
resp = client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def list_doctors(mcp_username: str) -> Any:
|
||||||
|
return _request("GET", "/api/doctors", mcp_username=mcp_username)
|
||||||
|
|
||||||
|
|
||||||
|
def get_shift_assignments(
|
||||||
|
mcp_username: str,
|
||||||
|
*,
|
||||||
|
from_date: Optional[str] = None,
|
||||||
|
to_date: Optional[str] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Any:
|
||||||
|
params: dict = {}
|
||||||
|
if from_date:
|
||||||
|
params["from"] = from_date
|
||||||
|
if to_date:
|
||||||
|
params["to"] = to_date
|
||||||
|
data = _request("GET", "/api/shift-assignments", mcp_username=mcp_username, params=params or None)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data[:limit]
|
||||||
|
if isinstance(data, dict):
|
||||||
|
items = data.get("assignments") or data.get("items") or data.get("results")
|
||||||
|
if isinstance(items, list):
|
||||||
|
return items[:limit]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_my_shifts(
|
||||||
|
mcp_username: str,
|
||||||
|
*,
|
||||||
|
from_date: Optional[str] = None,
|
||||||
|
to_date: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict:
|
||||||
|
"""Turni dell'utente MCP: filtra per doctorId collegato o per nome medico."""
|
||||||
|
user_info = _request("GET", "/api/users/me", mcp_username=mcp_username)
|
||||||
|
doctor_id = user_info.get("doctorId")
|
||||||
|
assignments = get_shift_assignments(
|
||||||
|
mcp_username, from_date=from_date, to_date=to_date, limit=500,
|
||||||
|
)
|
||||||
|
if not isinstance(assignments, list):
|
||||||
|
return {"user": user_info, "assignments": assignments}
|
||||||
|
if doctor_id:
|
||||||
|
mine = [a for a in assignments if a.get("doctorId") == doctor_id or a.get("doctor_id") == doctor_id]
|
||||||
|
else:
|
||||||
|
service_user = _service_username(mcp_username)
|
||||||
|
doctors = list_doctors(mcp_username)
|
||||||
|
doc_ids = set()
|
||||||
|
if isinstance(doctors, list):
|
||||||
|
for doc in doctors:
|
||||||
|
name = (doc.get("name") or doc.get("fullName") or "").lower()
|
||||||
|
if service_user.lower() in name or mcp_username.lower() in name:
|
||||||
|
doc_ids.add(doc.get("id") or doc.get("doctorId"))
|
||||||
|
mine = [
|
||||||
|
a for a in assignments
|
||||||
|
if (a.get("doctorId") or a.get("doctor_id")) in doc_ids
|
||||||
|
] if doc_ids else assignments[:limit]
|
||||||
|
return {
|
||||||
|
"user": {
|
||||||
|
"username": user_info.get("username"),
|
||||||
|
"role": user_info.get("role"),
|
||||||
|
"doctorId": doctor_id,
|
||||||
|
},
|
||||||
|
"assignments": mine[:limit],
|
||||||
|
"count": len(mine),
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""JWT utilities for OAuth access tokens."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from .db import get_conn
|
||||||
|
|
||||||
|
JWT_ALG = "HS256"
|
||||||
|
ACCESS_TOKEN_HOURS = 1
|
||||||
|
REFRESH_TOKEN_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def jwt_secret() -> str:
|
||||||
|
secret = os.environ.get("MCP_JWT_SECRET", "").strip()
|
||||||
|
if not secret:
|
||||||
|
secret = secrets.token_urlsafe(48)
|
||||||
|
os.environ["MCP_JWT_SECRET"] = secret
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
def base_url() -> str:
|
||||||
|
return os.environ.get("MCP_BASE_URL", "https://mcp.loogle.it").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def scopes_for_user(user: dict, requested: str) -> str:
|
||||||
|
parts = [s for s in requested.split() if s]
|
||||||
|
allowed = {
|
||||||
|
"context:read",
|
||||||
|
"context:write",
|
||||||
|
"knowledge:read",
|
||||||
|
"knowledge:write",
|
||||||
|
"gitea:read",
|
||||||
|
"gitea:write",
|
||||||
|
"home:read",
|
||||||
|
"irrigation:read",
|
||||||
|
"turni:read",
|
||||||
|
}
|
||||||
|
if user.get("is_admin"):
|
||||||
|
allowed.add("admin")
|
||||||
|
filtered = [s for s in parts if s in allowed]
|
||||||
|
if not filtered:
|
||||||
|
filtered = [
|
||||||
|
"context:read",
|
||||||
|
"context:write",
|
||||||
|
"knowledge:read",
|
||||||
|
"knowledge:write",
|
||||||
|
"gitea:read",
|
||||||
|
"gitea:write",
|
||||||
|
"home:read",
|
||||||
|
"irrigation:read",
|
||||||
|
"turni:read",
|
||||||
|
]
|
||||||
|
return " ".join(filtered)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user: dict, scope: str, client_id: str) -> tuple[str, str]:
|
||||||
|
jti = str(uuid.uuid4())
|
||||||
|
now = datetime.datetime.utcnow()
|
||||||
|
payload = {
|
||||||
|
"iss": base_url(),
|
||||||
|
"sub": user["username"],
|
||||||
|
"uid": user["id"],
|
||||||
|
"scope": scope,
|
||||||
|
"client_id": client_id,
|
||||||
|
"jti": jti,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + datetime.timedelta(hours=ACCESS_TOKEN_HOURS),
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, jwt_secret(), algorithm=JWT_ALG)
|
||||||
|
return token, jti
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(user_id: int, scope: str, client_id: str) -> str:
|
||||||
|
token = secrets.token_urlsafe(48)
|
||||||
|
expires = (
|
||||||
|
datetime.datetime.utcnow() + datetime.timedelta(days=REFRESH_TOKEN_DAYS)
|
||||||
|
).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
get_conn().execute(
|
||||||
|
"INSERT INTO refresh_tokens(token,user_id,client_id,scope,expires_at) VALUES (?,?,?,?,?)",
|
||||||
|
(token, user_id, client_id, scope, expires),
|
||||||
|
)
|
||||||
|
get_conn().commit()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, jwt_secret(), algorithms=[JWT_ALG], issuer=base_url())
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return None
|
||||||
|
jti = payload.get("jti")
|
||||||
|
if not jti:
|
||||||
|
return None
|
||||||
|
row = get_conn().execute(
|
||||||
|
"SELECT 1 FROM revoked_jtis WHERE jti=? AND expires_at > datetime('now')",
|
||||||
|
(jti,),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_jti(jti: str, expires_at: datetime.datetime) -> None:
|
||||||
|
get_conn().execute(
|
||||||
|
"INSERT OR IGNORE INTO revoked_jtis(jti,expires_at) VALUES (?,?)",
|
||||||
|
(jti, expires_at.strftime("%Y-%m-%d %H:%M:%S")),
|
||||||
|
)
|
||||||
|
get_conn().commit()
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_refresh_token(token: str) -> None:
|
||||||
|
get_conn().execute("UPDATE refresh_tokens SET revoked=1 WHERE token=?", (token,))
|
||||||
|
get_conn().commit()
|
||||||
|
|
||||||
|
|
||||||
|
def consume_refresh_token(token: str) -> Optional[dict]:
|
||||||
|
row = get_conn().execute(
|
||||||
|
"SELECT * FROM refresh_tokens WHERE token=? AND revoked=0 AND expires_at > datetime('now')",
|
||||||
|
(token,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def has_scope(claims: dict, scope: str) -> bool:
|
||||||
|
scopes = set((claims.get("scope") or "").split())
|
||||||
|
if "admin" in scopes:
|
||||||
|
return True
|
||||||
|
return scope in scopes
|
||||||
Whitespace-only changes.
@@ -0,0 +1,295 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""RAG su export Irrigazione + Turni (P7)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from ..db import get_conn
|
||||||
|
from ..integrations import irrigazione, turni
|
||||||
|
from . import embeddings, qdrant_store
|
||||||
|
from .text_chunk import chunk_text as _chunk_text
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("loogle_mcp.apps_indexer")
|
||||||
|
|
||||||
|
INDEX_USER = os.environ.get("APPS_INDEX_USER", "daniele").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _int_env(name: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(os.environ.get(name, str(default)))
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled() -> bool:
|
||||||
|
flag = os.environ.get("APPS_INDEX_ENABLED", "yes").strip().lower()
|
||||||
|
return flag not in ("0", "false", "no", "off")
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_id(source: str, record_id: str) -> int:
|
||||||
|
key = f"{source}:{record_id}"
|
||||||
|
return abs(hash(key)) % (2**31 - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _index_text(source: str, record_id: str, title: str, text: str) -> dict:
|
||||||
|
chunks = _chunk_text(_truncate(text))
|
||||||
|
if len(chunks) > 8:
|
||||||
|
chunks = chunks[:8]
|
||||||
|
if not chunks:
|
||||||
|
return {"source": source, "record_id": record_id, "chunks": 0, "skipped": True}
|
||||||
|
vectors = embeddings.embed_texts(chunks)
|
||||||
|
collection = qdrant_store.APPS_SHARED_COLLECTION
|
||||||
|
doc_id = _doc_id(source, record_id)
|
||||||
|
qdrant_store.delete_by_doc(collection, doc_id)
|
||||||
|
ids = []
|
||||||
|
payloads = []
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
point_id = f"app-{source}-{record_id}-chunk-{i}"
|
||||||
|
ids.append(point_id)
|
||||||
|
payloads.append(
|
||||||
|
{
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"source": source,
|
||||||
|
"record_id": record_id,
|
||||||
|
"chunk_index": i,
|
||||||
|
"title": title,
|
||||||
|
"text": chunk,
|
||||||
|
"owner": "family",
|
||||||
|
"visibility": "family",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
|
||||||
|
get_conn().execute(
|
||||||
|
"INSERT INTO indexed_apps_records(source,record_id,title,owner,chunk_count,indexed_at)"
|
||||||
|
" VALUES (?,?,?,?,?,datetime('now'))"
|
||||||
|
" ON CONFLICT(source,record_id) DO UPDATE SET"
|
||||||
|
" title=excluded.title, chunk_count=excluded.chunk_count, indexed_at=datetime('now')",
|
||||||
|
(source, record_id, title, "family", len(chunks)),
|
||||||
|
)
|
||||||
|
get_conn().commit()
|
||||||
|
return {"source": source, "record_id": record_id, "chunks": len(chunks), "collection": collection}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_irrigation_history_item(item: dict, idx: int) -> str:
|
||||||
|
parts = [f"Irrigazione storico #{idx}"]
|
||||||
|
for key in ("started_at", "ended_at", "zone", "zone_name", "duration_min", "volume_l", "mode", "note"):
|
||||||
|
if item.get(key) is not None:
|
||||||
|
parts.append(f"{key}: {item[key]}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_irrigation_event(item: dict, idx: int) -> str:
|
||||||
|
parts = [f"Irrigazione evento #{idx}"]
|
||||||
|
for key in ("ts", "time", "type", "level", "message", "zone", "detail"):
|
||||||
|
if item.get(key) is not None:
|
||||||
|
parts.append(f"{key}: {item[key]}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_turni_assignment(item: dict, idx: int) -> str:
|
||||||
|
parts = [f"Turno #{idx}"]
|
||||||
|
for key in (
|
||||||
|
"date", "startDate", "endDate", "doctorId", "doctorName", "doctor_name",
|
||||||
|
"slotId", "slotName", "slot_name", "uoc", "uocName", "shiftType", "notes",
|
||||||
|
):
|
||||||
|
if item.get(key) is not None:
|
||||||
|
parts.append(f"{key}: {item[key]}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str, limit: int = 6000) -> str:
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[: limit - 20] + "\n… [truncated]"
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_irrigation_status(status: dict) -> str:
|
||||||
|
lines = ["Irrigazione — snapshot stato"]
|
||||||
|
for key in ("plan_mode", "program", "simulation", "hibernation", "draining"):
|
||||||
|
if key in status:
|
||||||
|
lines.append(f"{key}: {status[key]}")
|
||||||
|
ha = status.get("ha") or {}
|
||||||
|
lines.append(f"ha_connected: {ha.get('connected')}")
|
||||||
|
zones = status.get("zones") or []
|
||||||
|
lines.append(f"zone_count: {len(zones)}")
|
||||||
|
for z in zones[:12]:
|
||||||
|
if isinstance(z, dict):
|
||||||
|
lines.append(
|
||||||
|
f" - {z.get('name', z.get('id'))}: state={z.get('ha_state')} excluded={z.get('excluded')}"
|
||||||
|
)
|
||||||
|
analysis = status.get("analysis")
|
||||||
|
if isinstance(analysis, dict):
|
||||||
|
for k, v in list(analysis.items())[:8]:
|
||||||
|
lines.append(f"analysis.{k}: {v}")
|
||||||
|
elif analysis:
|
||||||
|
lines.append(f"analysis: {analysis}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_zones(zones: Any) -> str:
|
||||||
|
items = zones if isinstance(zones, list) else _normalize_list(zones)
|
||||||
|
lines = [f"Irrigazione — zone ({len(items)})"]
|
||||||
|
for z in items[:20]:
|
||||||
|
if not isinstance(z, dict):
|
||||||
|
continue
|
||||||
|
lines.append(
|
||||||
|
f"- {z.get('name', z.get('id'))}: ha={z.get('ha_state')} "
|
||||||
|
f"rate_mmh={z.get('rate_mmh')} flow={z.get('zone_flow_lph')}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_list(data: Any) -> list:
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key in ("items", "results", "history", "events", "assignments", "records"):
|
||||||
|
val = data.get(key)
|
||||||
|
if isinstance(val, list):
|
||||||
|
return val
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def index_irrigazione(*, username: Optional[str] = None, history_limit: Optional[int] = None, events_limit: Optional[int] = None) -> dict:
|
||||||
|
user = username or INDEX_USER
|
||||||
|
history_limit = history_limit if history_limit is not None else _int_env("APPS_INDEX_HISTORY_LIMIT", 25)
|
||||||
|
events_limit = events_limit if events_limit is not None else _int_env("APPS_INDEX_EVENTS_LIMIT", 40)
|
||||||
|
if not irrigazione.is_configured(user):
|
||||||
|
return {"source": "irrigazione", "skipped": True, "reason": "not configured"}
|
||||||
|
indexed = 0
|
||||||
|
errors = 0
|
||||||
|
try:
|
||||||
|
status = irrigazione.get_status(user)
|
||||||
|
status_text = _summarize_irrigation_status(status)
|
||||||
|
_index_text("irrigazione", "status-snapshot", "Irrigazione — stato attuale", status_text)
|
||||||
|
indexed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Irrigazione status index failed: %s", exc)
|
||||||
|
errors += 1
|
||||||
|
try:
|
||||||
|
zones = irrigazione.get_zones(user)
|
||||||
|
zones_text = _summarize_zones(zones)
|
||||||
|
_index_text("irrigazione", "zones-snapshot", "Irrigazione — zone", zones_text)
|
||||||
|
indexed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Irrigazione zones index failed: %s", exc)
|
||||||
|
errors += 1
|
||||||
|
try:
|
||||||
|
history = irrigazione.get_history(user, limit=history_limit)
|
||||||
|
for i, item in enumerate(_normalize_list(history)):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
rid = str(item.get("id") or item.get("started_at") or i)
|
||||||
|
text = _format_irrigation_history_item(item, i)
|
||||||
|
_index_text("irrigazione", f"history-{rid}", f"Irrigazione storico {rid}", text)
|
||||||
|
indexed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Irrigazione history index failed: %s", exc)
|
||||||
|
errors += 1
|
||||||
|
try:
|
||||||
|
events = irrigazione.get_events(user, limit=events_limit)
|
||||||
|
for i, item in enumerate(_normalize_list(events)):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
rid = str(item.get("id") or item.get("ts") or item.get("time") or i)
|
||||||
|
text = _format_irrigation_event(item, i)
|
||||||
|
_index_text("irrigazione", f"event-{rid}", f"Irrigazione evento {rid}", text)
|
||||||
|
indexed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Irrigazione events index failed: %s", exc)
|
||||||
|
errors += 1
|
||||||
|
try:
|
||||||
|
lavori = irrigazione.get_lavori_summary(user)
|
||||||
|
lavori_text = _truncate(json.dumps(lavori, ensure_ascii=False, indent=2), 4000)
|
||||||
|
_index_text("irrigazione", "lavori-summary", "Irrigazione — lavori manutenzione", lavori_text)
|
||||||
|
indexed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Irrigazione lavori index failed: %s", exc)
|
||||||
|
errors += 1
|
||||||
|
return {"source": "irrigazione", "indexed": indexed, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
def index_turni(*, username: Optional[str] = None, assignments_limit: Optional[int] = None) -> dict:
|
||||||
|
user = username or INDEX_USER
|
||||||
|
assignments_limit = assignments_limit if assignments_limit is not None else _int_env("APPS_INDEX_ASSIGNMENTS_LIMIT", 80)
|
||||||
|
if not turni.is_configured(user):
|
||||||
|
return {"source": "turni", "skipped": True, "reason": "not configured"}
|
||||||
|
indexed = 0
|
||||||
|
errors = 0
|
||||||
|
try:
|
||||||
|
status = turni.get_status()
|
||||||
|
status_text = _truncate(json.dumps(status, ensure_ascii=False, indent=2), 2000)
|
||||||
|
_index_text("turni", "status-snapshot", "Turni — stato servizio", status_text)
|
||||||
|
indexed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Turni status index failed: %s", exc)
|
||||||
|
errors += 1
|
||||||
|
try:
|
||||||
|
doctors = turni.list_doctors(user)
|
||||||
|
lines = ["Turni — medici"]
|
||||||
|
for d in (_normalize_list(doctors) if not isinstance(doctors, list) else doctors)[:40]:
|
||||||
|
if isinstance(d, dict):
|
||||||
|
lines.append(f"- {d.get('name', d.get('fullName'))} id={d.get('id')}")
|
||||||
|
_index_text("turni", "doctors-list", "Turni — elenco medici", "\n".join(lines))
|
||||||
|
indexed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Turni doctors index failed: %s", exc)
|
||||||
|
errors += 1
|
||||||
|
try:
|
||||||
|
assignments = turni.get_shift_assignments(user, limit=assignments_limit)
|
||||||
|
for i, item in enumerate(_normalize_list(assignments)):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
rid = str(item.get("id") or item.get("date") or i)
|
||||||
|
text = _format_turni_assignment(item, i)
|
||||||
|
_index_text("turni", f"assignment-{rid}", f"Turno {rid}", text)
|
||||||
|
indexed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Turni assignments index failed: %s", exc)
|
||||||
|
errors += 1
|
||||||
|
return {"source": "turni", "indexed": indexed, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
def index_all(*, username: Optional[str] = None) -> dict:
|
||||||
|
if not _enabled():
|
||||||
|
return {"skipped": True, "reason": "APPS_INDEX_ENABLED=no"}
|
||||||
|
user = username or INDEX_USER
|
||||||
|
return {
|
||||||
|
"irrigazione": index_irrigazione(username=user),
|
||||||
|
"turni": index_turni(username=user),
|
||||||
|
"at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def search_apps_knowledge(query: str, limit: int = 8, source: Optional[str] = None) -> list:
|
||||||
|
vectors = embeddings.embed_texts([query])
|
||||||
|
flt = {"source": source} if source else None
|
||||||
|
hits = qdrant_store.search(
|
||||||
|
[qdrant_store.APPS_SHARED_COLLECTION],
|
||||||
|
vectors[0],
|
||||||
|
limit=limit,
|
||||||
|
visibility_filter=flt,
|
||||||
|
)
|
||||||
|
for hit in hits:
|
||||||
|
hit.setdefault("source_type", hit.get("source", "apps"))
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def list_indexed_records(source: Optional[str] = None, limit: int = 40) -> list:
|
||||||
|
conn = get_conn()
|
||||||
|
if source:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM indexed_apps_records WHERE source=? ORDER BY indexed_at DESC LIMIT ?",
|
||||||
|
(source, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM indexed_apps_records ORDER BY indexed_at DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Embedding providers — con thermal gate e keep_alive adattivo."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from . import thermal
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("loogle_mcp.embeddings")
|
||||||
|
|
||||||
|
|
||||||
|
def embed_texts(texts: list[str]) -> list[list[float]]:
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
ollama_url = os.environ.get("OLLAMA_URL", "").strip()
|
||||||
|
if ollama_url:
|
||||||
|
try:
|
||||||
|
return _embed_ollama(texts, ollama_url)
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Ollama embedding failed: %s", exc)
|
||||||
|
openai_key = os.environ.get("OPENAI_API_KEY", "").strip()
|
||||||
|
if openai_key:
|
||||||
|
return _embed_openai(texts, openai_key)
|
||||||
|
raise RuntimeError("Nessun provider embedding configurato (OLLAMA_URL o OPENAI_API_KEY)")
|
||||||
|
|
||||||
|
|
||||||
|
def _embed_ollama(texts: list[str], base_url: str) -> list[list[float]]:
|
||||||
|
model = os.environ.get("OLLAMA_EMBED_MODEL", "nomic-embed-text")
|
||||||
|
vectors = []
|
||||||
|
timeout = httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
for i, text in enumerate(texts):
|
||||||
|
status = thermal.wait_for_headroom(context=f"embed:{i+1}/{len(texts)}")
|
||||||
|
keep_alive = thermal.suggested_keep_alive(status)
|
||||||
|
delay = thermal.suggested_delay_s(status)
|
||||||
|
|
||||||
|
payload = {"model": model, "prompt": text, "keep_alive": keep_alive}
|
||||||
|
# options.num_thread limita i thread CPU lato Ollama (se supportato)
|
||||||
|
num_thread = os.environ.get("OLLAMA_NUM_THREAD", "").strip()
|
||||||
|
if num_thread:
|
||||||
|
try:
|
||||||
|
payload["options"] = {"num_thread": int(num_thread)}
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
resp = client.post(f"{base_url.rstrip('/')}/api/embeddings", json=payload)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Ollama embeddings HTTP %s: %s — payload keys=%s",
|
||||||
|
resp.status_code,
|
||||||
|
resp.text[:300],
|
||||||
|
list(payload.keys()),
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
vectors.append(resp.json()["embedding"])
|
||||||
|
|
||||||
|
if delay > 0 and i + 1 < len(texts):
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
# Unload solo se esplicitamente richiesto (zona HARD) — evita spike da reload
|
||||||
|
if keep_alive == 0:
|
||||||
|
try:
|
||||||
|
client.post(
|
||||||
|
f"{base_url.rstrip('/')}/api/generate",
|
||||||
|
json={"model": model, "keep_alive": 0},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return vectors
|
||||||
|
|
||||||
|
|
||||||
|
def _embed_openai(texts: list[str], api_key: str) -> list[list[float]]:
|
||||||
|
model = os.environ.get("OPENAI_EMBED_MODEL", "text-embedding-3-small")
|
||||||
|
with httpx.Client(timeout=120.0) as client:
|
||||||
|
resp = client.post(
|
||||||
|
"https://api.openai.com/v1/embeddings",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
json={"model": model, "input": texts},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()["data"]
|
||||||
|
return [item["embedding"] for item in sorted(data, key=lambda x: x["index"])]
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Gitea REST API client — token per utente MCP."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("loogle_mcp.gitea")
|
||||||
|
|
||||||
|
GITEA_URL = os.environ.get("GITEA_URL", "https://git.loogle.it").rstrip("/")
|
||||||
|
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||||
|
GITEA_API_TOKEN_SCOPES = "read:repository,write:repository,write:issue,write:user,read:user"
|
||||||
|
TEXT_EXTENSIONS = {
|
||||||
|
".md", ".txt", ".py", ".sh", ".yml", ".yaml", ".json", ".toml", ".ini",
|
||||||
|
".conf", ".js", ".ts", ".tsx", ".jsx", ".html", ".css", ".sql", ".go",
|
||||||
|
".rs", ".env", ".service", ".timer", ".xml", ".csv",
|
||||||
|
}
|
||||||
|
_tokens_cache: Optional[dict[str, str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tokens() -> dict[str, str]:
|
||||||
|
global _tokens_cache
|
||||||
|
if _tokens_cache is not None:
|
||||||
|
return _tokens_cache
|
||||||
|
|
||||||
|
tokens: dict[str, str] = {}
|
||||||
|
json_map = os.environ.get("GITEA_API_TOKENS", "").strip()
|
||||||
|
if json_map:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(json_map)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
tokens.update({k.lower(): v for k, v in parsed.items() if v})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
LOGGER.warning("GITEA_API_TOKENS non è JSON valido")
|
||||||
|
|
||||||
|
fallback = os.environ.get("GITEA_API_TOKEN", "").strip()
|
||||||
|
for user in MCP_USERS:
|
||||||
|
env_key = f"GITEA_API_TOKEN_{user.upper()}"
|
||||||
|
token = os.environ.get(env_key, "").strip()
|
||||||
|
if token:
|
||||||
|
tokens[user] = token
|
||||||
|
elif user not in tokens and fallback:
|
||||||
|
tokens[user] = fallback
|
||||||
|
|
||||||
|
if not tokens and fallback:
|
||||||
|
tokens["daniele"] = fallback
|
||||||
|
|
||||||
|
_tokens_cache = tokens
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def list_configured_users() -> list[str]:
|
||||||
|
return list(_load_tokens().keys())
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured(username: Optional[str] = None) -> bool:
|
||||||
|
tokens = _load_tokens()
|
||||||
|
if not tokens:
|
||||||
|
return False
|
||||||
|
if username:
|
||||||
|
user = username.lower()
|
||||||
|
return user in tokens or "daniele" in tokens or bool(tokens)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(username: Optional[str] = None) -> dict[str, str]:
|
||||||
|
tokens = _load_tokens()
|
||||||
|
if not tokens:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Nessun token Gitea configurato. "
|
||||||
|
"Imposta GITEA_API_TOKEN o GITEA_API_TOKEN_{USER} in .env — vedi docs/GITEA-TOKEN.md"
|
||||||
|
)
|
||||||
|
user = (username or "daniele").lower()
|
||||||
|
token = tokens.get(user) or tokens.get("daniele") or next(iter(tokens.values()))
|
||||||
|
return {"Authorization": f"token {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_repo(repo: str) -> tuple[str, str]:
|
||||||
|
cleaned = repo.strip().strip("/")
|
||||||
|
if cleaned.count("/") != 1:
|
||||||
|
raise ValueError("repo deve essere nel formato owner/name (es. daniele/rete)")
|
||||||
|
owner, name = cleaned.split("/", 1)
|
||||||
|
if not owner or not name:
|
||||||
|
raise ValueError("repo deve essere nel formato owner/name (es. daniele/rete)")
|
||||||
|
return owner, name
|
||||||
|
|
||||||
|
|
||||||
|
def api_base_url() -> str:
|
||||||
|
return os.environ.get("GITEA_API_URL", GITEA_URL).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def public_base_url() -> str:
|
||||||
|
return os.environ.get("GITEA_URL", "https://git.loogle.it").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _api_url(path: str) -> str:
|
||||||
|
return f"{api_base_url()}/api/v1{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
params: Optional[dict] = None,
|
||||||
|
json_body: Optional[dict] = None,
|
||||||
|
) -> Any:
|
||||||
|
with httpx.Client(timeout=60.0, verify=True) as client:
|
||||||
|
resp = client.request(
|
||||||
|
method,
|
||||||
|
_api_url(path),
|
||||||
|
headers=_headers(username),
|
||||||
|
params=params,
|
||||||
|
json=json_body,
|
||||||
|
)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise FileNotFoundError(resp.text or "Risorsa Gitea non trovata")
|
||||||
|
resp.raise_for_status()
|
||||||
|
if resp.content:
|
||||||
|
return resp.json()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def list_repos(
|
||||||
|
username: Optional[str] = None,
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict:
|
||||||
|
data = _request(
|
||||||
|
"GET",
|
||||||
|
"/user/repos",
|
||||||
|
username=username,
|
||||||
|
params={"page": page, "limit": limit, "sort": "updated"},
|
||||||
|
)
|
||||||
|
repos = []
|
||||||
|
for repo in data if isinstance(data, list) else []:
|
||||||
|
full_name = repo.get("full_name") or ""
|
||||||
|
if not full_name and repo.get("owner"):
|
||||||
|
full_name = f"{repo['owner'].get('login', '')}/{repo.get('name', '')}"
|
||||||
|
repos.append(
|
||||||
|
{
|
||||||
|
"full_name": full_name,
|
||||||
|
"description": repo.get("description") or "",
|
||||||
|
"private": bool(repo.get("private")),
|
||||||
|
"html_url": repo.get("html_url") or f"{public_base_url()}/{full_name}",
|
||||||
|
"default_branch": repo.get("default_branch") or "main",
|
||||||
|
"updated_at": repo.get("updated_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"repos": repos, "page": page, "count": len(repos)}
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_content(entry: dict) -> str:
|
||||||
|
encoding = (entry.get("encoding") or "").lower()
|
||||||
|
raw = entry.get("content") or ""
|
||||||
|
if encoding == "base64":
|
||||||
|
return base64.b64decode(raw).decode("utf-8", errors="replace")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def get_file(
|
||||||
|
repo: str,
|
||||||
|
path: str,
|
||||||
|
ref: Optional[str] = None,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
owner, name = parse_repo(repo)
|
||||||
|
file_path = path.lstrip("/")
|
||||||
|
params = {}
|
||||||
|
if ref:
|
||||||
|
params["ref"] = ref
|
||||||
|
encoded_path = "/".join(quote(part, safe="") for part in file_path.split("/"))
|
||||||
|
data = _request(
|
||||||
|
"GET",
|
||||||
|
f"/repos/{owner}/{name}/contents/{encoded_path}",
|
||||||
|
username=username,
|
||||||
|
params=params or None,
|
||||||
|
)
|
||||||
|
if isinstance(data, list):
|
||||||
|
entries = [
|
||||||
|
{
|
||||||
|
"name": item.get("name"),
|
||||||
|
"path": item.get("path"),
|
||||||
|
"type": item.get("type"),
|
||||||
|
"size": item.get("size"),
|
||||||
|
}
|
||||||
|
for item in data
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"repo": f"{owner}/{name}",
|
||||||
|
"path": file_path or "/",
|
||||||
|
"type": "dir",
|
||||||
|
"entries": entries,
|
||||||
|
}
|
||||||
|
content = _decode_content(data)
|
||||||
|
return {
|
||||||
|
"repo": f"{owner}/{name}",
|
||||||
|
"path": data.get("path") or file_path,
|
||||||
|
"type": data.get("type") or "file",
|
||||||
|
"size": data.get("size"),
|
||||||
|
"sha": data.get("sha"),
|
||||||
|
"html_url": data.get("html_url") or f"{public_base_url()}/{owner}/{name}/src/branch/{ref or 'main'}/{file_path}",
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def search_code(
|
||||||
|
query: str,
|
||||||
|
repo: Optional[str] = None,
|
||||||
|
limit: int = 20,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
q = query.strip()
|
||||||
|
if not q:
|
||||||
|
raise ValueError("query obbligatoria")
|
||||||
|
params: dict[str, Any] = {"q": q, "limit": min(max(limit, 1), 50)}
|
||||||
|
if repo:
|
||||||
|
owner, name = parse_repo(repo)
|
||||||
|
params["repo"] = f"{owner}/{name}"
|
||||||
|
try:
|
||||||
|
data = _request("GET", "/search/code", username=username, params=params)
|
||||||
|
hits = []
|
||||||
|
for item in data.get("data") or []:
|
||||||
|
repo_name = item.get("repository", {}).get("full_name") or item.get("repository", {}).get("name")
|
||||||
|
hits.append(
|
||||||
|
{
|
||||||
|
"repo": repo_name,
|
||||||
|
"path": item.get("path"),
|
||||||
|
"sha": item.get("sha"),
|
||||||
|
"html_url": item.get("url") or item.get("html_url"),
|
||||||
|
"language": item.get("language"),
|
||||||
|
"snippet": (item.get("content") or item.get("text") or "")[:500],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"query": q, "repo": repo, "results": hits, "count": len(hits)}
|
||||||
|
except FileNotFoundError:
|
||||||
|
return _search_code_fallback(q, repo, limit, username)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if exc.response.status_code not in (404, 422):
|
||||||
|
raise
|
||||||
|
return _search_code_fallback(q, repo, limit, username)
|
||||||
|
|
||||||
|
|
||||||
|
def _search_code_fallback(
|
||||||
|
query: str,
|
||||||
|
repo: Optional[str],
|
||||||
|
limit: int,
|
||||||
|
username: Optional[str],
|
||||||
|
) -> dict:
|
||||||
|
"""Fallback se /search/code non disponibile: tree + grep su file testo."""
|
||||||
|
repos: list[str] = []
|
||||||
|
if repo:
|
||||||
|
owner, name = parse_repo(repo)
|
||||||
|
repos.append(f"{owner}/{name}")
|
||||||
|
else:
|
||||||
|
listed = list_repos(username=username, limit=20)
|
||||||
|
repos = [r["full_name"] for r in listed["repos"] if r.get("full_name")]
|
||||||
|
|
||||||
|
terms = [t.lower() for t in re.split(r"\s+", query) if t]
|
||||||
|
hits: list[dict] = []
|
||||||
|
max_files = min(limit * 3, 40)
|
||||||
|
|
||||||
|
for full_name in repos:
|
||||||
|
owner, name = parse_repo(full_name)
|
||||||
|
try:
|
||||||
|
tree = _request(
|
||||||
|
"GET",
|
||||||
|
f"/repos/{owner}/{name}/git/trees/HEAD",
|
||||||
|
username=username,
|
||||||
|
params={"recursive": "1"},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
scanned = 0
|
||||||
|
for node in tree.get("tree") or []:
|
||||||
|
if node.get("type") != "blob":
|
||||||
|
continue
|
||||||
|
path = node.get("path") or ""
|
||||||
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
if ext and ext not in TEXT_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
if any(term in path.lower() for term in terms):
|
||||||
|
pass
|
||||||
|
scanned += 1
|
||||||
|
if scanned > max_files:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
file_data = get_file(full_name, path, username=username)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
content = (file_data.get("content") or "").lower()
|
||||||
|
if not any(term in content or term in path.lower() for term in terms):
|
||||||
|
continue
|
||||||
|
snippet = file_data.get("content") or ""
|
||||||
|
idx = snippet.lower().find(terms[0]) if terms else 0
|
||||||
|
if idx < 0:
|
||||||
|
idx = 0
|
||||||
|
hits.append(
|
||||||
|
{
|
||||||
|
"repo": full_name,
|
||||||
|
"path": path,
|
||||||
|
"sha": node.get("sha"),
|
||||||
|
"html_url": file_data.get("html_url"),
|
||||||
|
"snippet": snippet[max(0, idx - 80): idx + 420],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(hits) >= limit:
|
||||||
|
break
|
||||||
|
if len(hits) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {"query": query, "repo": repo, "results": hits[:limit], "count": len(hits[:limit]), "mode": "fallback"}
|
||||||
|
|
||||||
|
|
||||||
|
def list_issues(
|
||||||
|
repo: str,
|
||||||
|
state: str = "open",
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 20,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
owner, name = parse_repo(repo)
|
||||||
|
data = _request(
|
||||||
|
"GET",
|
||||||
|
f"/repos/{owner}/{name}/issues",
|
||||||
|
username=username,
|
||||||
|
params={"state": state, "page": page, "limit": limit, "type": "issues"},
|
||||||
|
)
|
||||||
|
issues = []
|
||||||
|
for item in data if isinstance(data, list) else []:
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"number": item.get("number"),
|
||||||
|
"title": item.get("title"),
|
||||||
|
"state": item.get("state"),
|
||||||
|
"user": (item.get("user") or {}).get("login"),
|
||||||
|
"html_url": item.get("html_url"),
|
||||||
|
"created_at": item.get("created_at"),
|
||||||
|
"updated_at": item.get("updated_at"),
|
||||||
|
"labels": [lbl.get("name") for lbl in (item.get("labels") or [])],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"repo": f"{owner}/{name}", "state": state, "issues": issues, "count": len(issues)}
|
||||||
|
|
||||||
|
|
||||||
|
def get_issue(
|
||||||
|
repo: str,
|
||||||
|
number: int,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
owner, name = parse_repo(repo)
|
||||||
|
item = _request("GET", f"/repos/{owner}/{name}/issues/{number}", username=username)
|
||||||
|
return {
|
||||||
|
"repo": f"{owner}/{name}",
|
||||||
|
"number": item.get("number"),
|
||||||
|
"title": item.get("title"),
|
||||||
|
"state": item.get("state"),
|
||||||
|
"body": item.get("body") or "",
|
||||||
|
"user": (item.get("user") or {}).get("login"),
|
||||||
|
"html_url": item.get("html_url"),
|
||||||
|
"created_at": item.get("created_at"),
|
||||||
|
"updated_at": item.get("updated_at"),
|
||||||
|
"labels": [lbl.get("name") for lbl in (item.get("labels") or [])],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_issue(
|
||||||
|
repo: str,
|
||||||
|
title: str,
|
||||||
|
body: str = "",
|
||||||
|
labels: Optional[list[str]] = None,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
owner, name = parse_repo(repo)
|
||||||
|
payload: dict[str, Any] = {"title": title.strip(), "body": body or ""}
|
||||||
|
if labels:
|
||||||
|
payload["labels"] = labels
|
||||||
|
item = _request(
|
||||||
|
"POST",
|
||||||
|
f"/repos/{owner}/{name}/issues",
|
||||||
|
username=username,
|
||||||
|
json_body=payload,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"repo": f"{owner}/{name}",
|
||||||
|
"number": item.get("number"),
|
||||||
|
"title": item.get("title"),
|
||||||
|
"state": item.get("state"),
|
||||||
|
"html_url": item.get("html_url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assert_repo_owner(username: str, repo: str, *, is_admin: bool = False) -> tuple[str, str]:
|
||||||
|
owner, name = parse_repo(repo)
|
||||||
|
if not is_admin and owner.lower() != username.lower():
|
||||||
|
raise PermissionError(
|
||||||
|
f"Puoi scrivere solo su repository di cui sei owner (repo {owner}/{name}, utente {username})"
|
||||||
|
)
|
||||||
|
return owner, name
|
||||||
|
|
||||||
|
|
||||||
|
def create_repo(
|
||||||
|
name: str,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
private: bool = True,
|
||||||
|
description: str = "",
|
||||||
|
auto_init: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
repo_name = name.strip().lower()
|
||||||
|
if not repo_name or not re.match(r"^[a-z0-9][a-z0-9._-]{0,99}$", repo_name):
|
||||||
|
raise ValueError("name repo non valido (usa lettere minuscole, numeri, -, _, .)")
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"name": repo_name,
|
||||||
|
"private": private,
|
||||||
|
"auto_init": auto_init,
|
||||||
|
"description": description.strip(),
|
||||||
|
}
|
||||||
|
item = _request("POST", "/user/repos", username=username, json_body=payload)
|
||||||
|
full_name = item.get("full_name") or f"{username}/{repo_name}"
|
||||||
|
return {
|
||||||
|
"full_name": full_name,
|
||||||
|
"private": bool(item.get("private", private)),
|
||||||
|
"html_url": item.get("html_url") or f"{public_base_url()}/{full_name}",
|
||||||
|
"default_branch": item.get("default_branch") or "main",
|
||||||
|
"description": item.get("description") or description,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_or_update_file(
|
||||||
|
repo: str,
|
||||||
|
path: str,
|
||||||
|
content: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
branch: Optional[str] = None,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
owner, repo_name = parse_repo(repo)
|
||||||
|
file_path = path.lstrip("/")
|
||||||
|
if not file_path:
|
||||||
|
raise ValueError("path obbligatorio")
|
||||||
|
if not message.strip():
|
||||||
|
raise ValueError("message commit obbligatorio")
|
||||||
|
|
||||||
|
encoded_path = "/".join(quote(part, safe="") for part in file_path.split("/"))
|
||||||
|
params = {}
|
||||||
|
if branch:
|
||||||
|
params["ref"] = branch
|
||||||
|
|
||||||
|
sha = None
|
||||||
|
action = "create"
|
||||||
|
try:
|
||||||
|
existing = get_file(repo, file_path, ref=branch, username=username)
|
||||||
|
if existing.get("type") == "file":
|
||||||
|
sha = existing.get("sha")
|
||||||
|
action = "update"
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
||||||
|
"message": message.strip(),
|
||||||
|
}
|
||||||
|
if sha:
|
||||||
|
body["sha"] = sha
|
||||||
|
if branch:
|
||||||
|
body["branch"] = branch
|
||||||
|
|
||||||
|
method = "PUT" if sha else "POST"
|
||||||
|
item = _request(
|
||||||
|
method,
|
||||||
|
f"/repos/{owner}/{repo_name}/contents/{encoded_path}",
|
||||||
|
username=username,
|
||||||
|
params=params or None,
|
||||||
|
json_body=body,
|
||||||
|
)
|
||||||
|
commit = item.get("commit") or {}
|
||||||
|
content_obj = item.get("content") or {}
|
||||||
|
return {
|
||||||
|
"repo": f"{owner}/{repo_name}",
|
||||||
|
"path": file_path,
|
||||||
|
"action": action,
|
||||||
|
"branch": branch or "default",
|
||||||
|
"sha": content_obj.get("sha"),
|
||||||
|
"commit_sha": commit.get("sha"),
|
||||||
|
"html_url": content_obj.get("html_url")
|
||||||
|
or f"{public_base_url()}/{owner}/{repo_name}/src/branch/{branch or 'main'}/{file_path}",
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Indicizzazione semantica (RAG) su file testo dei repository Gitea."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import zlib
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..db import get_conn
|
||||||
|
from . import embeddings, gitea, qdrant_store
|
||||||
|
from .text_chunk import CHUNK_OVERLAP, CHUNK_SIZE, chunk_text as _chunk_text
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("loogle_mcp.gitea_indexer")
|
||||||
|
|
||||||
|
SKIP_PATH_PARTS = (
|
||||||
|
"node_modules/",
|
||||||
|
"vendor/",
|
||||||
|
".git/",
|
||||||
|
"dist/",
|
||||||
|
"build/",
|
||||||
|
"__pycache__/",
|
||||||
|
".venv/",
|
||||||
|
"venv/",
|
||||||
|
".tox/",
|
||||||
|
"coverage/",
|
||||||
|
)
|
||||||
|
PRIORITY_PREFIXES = ("docs/", "ha/", "doc/", "README")
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled() -> bool:
|
||||||
|
flag = os.environ.get("GITEA_INDEX_ENABLED", "yes").strip().lower()
|
||||||
|
return flag not in ("0", "false", "no", "off")
|
||||||
|
|
||||||
|
|
||||||
|
def _max_files_per_repo() -> int:
|
||||||
|
return int(os.environ.get("GITEA_INDEX_MAX_FILES_PER_REPO", "150"))
|
||||||
|
|
||||||
|
|
||||||
|
def _max_file_bytes() -> int:
|
||||||
|
return int(os.environ.get("GITEA_INDEX_MAX_FILE_BYTES", "120000"))
|
||||||
|
|
||||||
|
|
||||||
|
def _repos_for_user(username: str) -> list[str]:
|
||||||
|
env_key = f"GITEA_INDEX_REPOS_{username.upper()}"
|
||||||
|
raw = os.environ.get(env_key, "").strip()
|
||||||
|
if raw:
|
||||||
|
repos: list[str] = []
|
||||||
|
for item in raw.split(","):
|
||||||
|
item = item.strip()
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
owner, name = gitea.parse_repo(item)
|
||||||
|
repos.append(f"{owner}/{name}")
|
||||||
|
return repos
|
||||||
|
repos: list[str] = []
|
||||||
|
page = 1
|
||||||
|
while page <= 5:
|
||||||
|
data = gitea.list_repos(username=username, page=page, limit=50)
|
||||||
|
batch = [r["full_name"] for r in data.get("repos") or [] if r.get("full_name")]
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
repos.extend(batch)
|
||||||
|
if len(batch) < 50:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return repos
|
||||||
|
|
||||||
|
|
||||||
|
def _should_index_path(path: str) -> bool:
|
||||||
|
lowered = path.lower()
|
||||||
|
if any(part in lowered for part in SKIP_PATH_PARTS):
|
||||||
|
return False
|
||||||
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
return bool(ext and ext in gitea.TEXT_EXTENSIONS)
|
||||||
|
|
||||||
|
|
||||||
|
def _path_priority(path: str) -> tuple[int, str]:
|
||||||
|
lowered = path.lower()
|
||||||
|
for idx, prefix in enumerate(PRIORITY_PREFIXES):
|
||||||
|
if lowered.startswith(prefix.lower()) or os.path.basename(lowered).startswith(prefix.lower()):
|
||||||
|
return (idx, path)
|
||||||
|
return (len(PRIORITY_PREFIXES), path)
|
||||||
|
|
||||||
|
|
||||||
|
def list_repo_text_files(repo: str, username: str) -> list[dict]:
|
||||||
|
owner, name = gitea.parse_repo(repo)
|
||||||
|
tree = gitea._request(
|
||||||
|
"GET",
|
||||||
|
f"/repos/{owner}/{name}/git/trees/HEAD",
|
||||||
|
username=username,
|
||||||
|
params={"recursive": "1"},
|
||||||
|
)
|
||||||
|
max_files = _max_files_per_repo()
|
||||||
|
max_bytes = _max_file_bytes()
|
||||||
|
files: list[dict] = []
|
||||||
|
for node in tree.get("tree") or []:
|
||||||
|
if node.get("type") != "blob":
|
||||||
|
continue
|
||||||
|
path = node.get("path") or ""
|
||||||
|
if not _should_index_path(path):
|
||||||
|
continue
|
||||||
|
size = int(node.get("size") or 0)
|
||||||
|
if size > max_bytes:
|
||||||
|
continue
|
||||||
|
files.append({"path": path, "sha": node.get("sha"), "size": size})
|
||||||
|
files.sort(key=lambda item: _path_priority(item["path"]))
|
||||||
|
return files[:max_files]
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_owner(repo: str) -> str:
|
||||||
|
owner, _ = gitea.parse_repo(repo)
|
||||||
|
return owner.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_visibility(private: bool, owner: str) -> str:
|
||||||
|
if private:
|
||||||
|
return "personal"
|
||||||
|
if owner in gitea.MCP_USERS:
|
||||||
|
return "family"
|
||||||
|
return "family"
|
||||||
|
|
||||||
|
|
||||||
|
def _collection_for_repo(private: bool, owner: str) -> str:
|
||||||
|
visibility = _repo_visibility(private, owner)
|
||||||
|
if visibility == "personal":
|
||||||
|
return qdrant_store.gitea_collection(owner)
|
||||||
|
return qdrant_store.GITEA_SHARED_COLLECTION
|
||||||
|
|
||||||
|
|
||||||
|
def _file_doc_id(repo: str, path: str) -> int:
|
||||||
|
return zlib.adler32(f"{repo}:{path}".encode("utf-8")) & 0x7FFFFFFF
|
||||||
|
|
||||||
|
|
||||||
|
def _file_title(repo: str, path: str) -> str:
|
||||||
|
return f"{repo}/{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def index_file(
|
||||||
|
repo: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
private: bool,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
owner = _repo_owner(repo)
|
||||||
|
conn = get_conn()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT sha, chunk_count FROM indexed_gitea_files WHERE repo=? AND path=?",
|
||||||
|
(repo, path),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
file_data = gitea.get_file(repo, path, username=username)
|
||||||
|
sha = file_data.get("sha") or ""
|
||||||
|
if row and row["sha"] == sha and not force:
|
||||||
|
return {"repo": repo, "path": path, "skipped": True, "sha": sha}
|
||||||
|
|
||||||
|
text = (file_data.get("content") or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {"repo": repo, "path": path, "chunks": 0, "sha": sha}
|
||||||
|
|
||||||
|
header = f"# {_file_title(repo, path)}\n\nSource: gitea:{repo}:{path}\n\n"
|
||||||
|
chunks = _chunk_text(header + text)
|
||||||
|
if not chunks:
|
||||||
|
return {"repo": repo, "path": path, "chunks": 0, "sha": sha}
|
||||||
|
|
||||||
|
visibility = _repo_visibility(private, owner)
|
||||||
|
collection = _collection_for_repo(private, owner)
|
||||||
|
doc_id = _file_doc_id(repo, path)
|
||||||
|
qdrant_store.delete_by_doc(collection, doc_id)
|
||||||
|
|
||||||
|
vectors = embeddings.embed_texts(chunks)
|
||||||
|
ids = []
|
||||||
|
payloads = []
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
point_id = f"gitea-{repo}-{path}-chunk-{i}"
|
||||||
|
ids.append(point_id)
|
||||||
|
payloads.append(
|
||||||
|
{
|
||||||
|
"source": "gitea",
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"repo": repo,
|
||||||
|
"path": path,
|
||||||
|
"chunk_index": i,
|
||||||
|
"title": _file_title(repo, path),
|
||||||
|
"text": chunk,
|
||||||
|
"owner": owner,
|
||||||
|
"visibility": visibility,
|
||||||
|
"sha": sha,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
|
||||||
|
|
||||||
|
stored = qdrant_store.count_by_doc(collection, doc_id)
|
||||||
|
if stored < len(chunks):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Qdrant upsert incompleto per {repo}/{path}: attesi {len(chunks)} chunk, trovati {stored}"
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO indexed_gitea_files(repo,path,sha,owner,visibility,chunk_count,indexed_at)"
|
||||||
|
" VALUES (?,?,?,?,?,?,datetime('now'))"
|
||||||
|
" ON CONFLICT(repo,path) DO UPDATE SET"
|
||||||
|
" sha=excluded.sha, owner=excluded.owner, visibility=excluded.visibility,"
|
||||||
|
" chunk_count=excluded.chunk_count, indexed_at=datetime('now')",
|
||||||
|
(repo, path, sha, owner, visibility, len(chunks)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {
|
||||||
|
"repo": repo,
|
||||||
|
"path": path,
|
||||||
|
"sha": sha,
|
||||||
|
"chunks": len(chunks),
|
||||||
|
"collection": collection,
|
||||||
|
"visibility": visibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def index_repo(
|
||||||
|
repo: str,
|
||||||
|
*,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
private: Optional[bool] = None,
|
||||||
|
force: bool = False,
|
||||||
|
max_files: Optional[int] = None,
|
||||||
|
) -> dict:
|
||||||
|
gitea_user = username or _repo_owner(repo)
|
||||||
|
if not gitea.is_configured(gitea_user):
|
||||||
|
raise RuntimeError(f"Gitea non configurato per {gitea_user}")
|
||||||
|
|
||||||
|
if private is None:
|
||||||
|
owner, name = gitea.parse_repo(repo)
|
||||||
|
meta = gitea._request("GET", f"/repos/{owner}/{name}", username=gitea_user)
|
||||||
|
private = bool(meta.get("private"))
|
||||||
|
|
||||||
|
files = list_repo_text_files(repo, gitea_user)
|
||||||
|
if max_files is not None:
|
||||||
|
files = files[: max(1, max_files)]
|
||||||
|
|
||||||
|
indexed = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
|
chunks = 0
|
||||||
|
for item in files:
|
||||||
|
try:
|
||||||
|
result = index_file(
|
||||||
|
repo,
|
||||||
|
item["path"],
|
||||||
|
username=gitea_user,
|
||||||
|
private=bool(private),
|
||||||
|
force=force,
|
||||||
|
)
|
||||||
|
if result.get("skipped"):
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
indexed += 1
|
||||||
|
chunks += int(result.get("chunks") or 0)
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Index gitea %s/%s failed: %s", repo, item.get("path"), exc)
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"repo": repo,
|
||||||
|
"files_seen": len(files),
|
||||||
|
"files_indexed": indexed,
|
||||||
|
"files_skipped": skipped,
|
||||||
|
"errors": errors,
|
||||||
|
"chunks": chunks,
|
||||||
|
"private": bool(private),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def index_all(max_files_per_repo: Optional[int] = None) -> dict:
|
||||||
|
if not _enabled():
|
||||||
|
return {"enabled": False, "indexed_files": 0}
|
||||||
|
|
||||||
|
total_indexed = 0
|
||||||
|
total_skipped = 0
|
||||||
|
total_errors = 0
|
||||||
|
total_chunks = 0
|
||||||
|
repos_done: list[str] = []
|
||||||
|
users = gitea.list_configured_users() or []
|
||||||
|
|
||||||
|
for username in users:
|
||||||
|
try:
|
||||||
|
repos = _repos_for_user(username)
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.error("Lista repo Gitea fallita per %s: %s", username, exc)
|
||||||
|
continue
|
||||||
|
for repo in repos:
|
||||||
|
try:
|
||||||
|
result = index_repo(
|
||||||
|
repo,
|
||||||
|
username=username,
|
||||||
|
force=False,
|
||||||
|
max_files=max_files_per_repo,
|
||||||
|
)
|
||||||
|
repos_done.append(repo)
|
||||||
|
total_indexed += int(result.get("files_indexed") or 0)
|
||||||
|
total_skipped += int(result.get("files_skipped") or 0)
|
||||||
|
total_errors += int(result.get("errors") or 0)
|
||||||
|
total_chunks += int(result.get("chunks") or 0)
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Index repo %s failed (%s): %s", repo, username, exc)
|
||||||
|
total_errors += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"repos": repos_done,
|
||||||
|
"files_indexed": total_indexed,
|
||||||
|
"files_skipped": total_skipped,
|
||||||
|
"errors": total_errors,
|
||||||
|
"chunks": total_chunks,
|
||||||
|
"users": users,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def gitea_collections_for_user(username: str, is_admin: bool = False) -> list[str]:
|
||||||
|
cols = [qdrant_store.GITEA_SHARED_COLLECTION, qdrant_store.gitea_collection(username)]
|
||||||
|
if is_admin:
|
||||||
|
for user in gitea.MCP_USERS:
|
||||||
|
cols.append(qdrant_store.gitea_collection(user))
|
||||||
|
return list(dict.fromkeys(cols))
|
||||||
|
|
||||||
|
|
||||||
|
def search_gitea_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list:
|
||||||
|
vectors = embeddings.embed_texts([query])
|
||||||
|
collections = gitea_collections_for_user(username, is_admin=is_admin)
|
||||||
|
hits = qdrant_store.search(collections, vectors[0], limit=limit)
|
||||||
|
for hit in hits:
|
||||||
|
hit["source"] = hit.get("source") or "gitea"
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def list_indexed_files(limit: int = 30, repo: Optional[str] = None) -> list:
|
||||||
|
conn = get_conn()
|
||||||
|
if repo:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM indexed_gitea_files WHERE repo=? ORDER BY indexed_at DESC LIMIT ?",
|
||||||
|
(repo, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM indexed_gitea_files ORDER BY indexed_at DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def index_stats() -> dict:
|
||||||
|
conn = get_conn()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COUNT(*), COALESCE(SUM(chunk_count), 0) FROM indexed_gitea_files"
|
||||||
|
).fetchone()
|
||||||
|
files_count = int(row[0] if row else 0)
|
||||||
|
chunks_meta = int(row[1] if row else 0)
|
||||||
|
collections = {qdrant_store.GITEA_SHARED_COLLECTION}
|
||||||
|
for user in gitea.MCP_USERS:
|
||||||
|
collections.add(qdrant_store.gitea_collection(user))
|
||||||
|
qdrant_points = sum(qdrant_store.collection_point_count(c) for c in collections)
|
||||||
|
return {
|
||||||
|
"files_indexed": files_count,
|
||||||
|
"chunks_in_metadata": chunks_meta,
|
||||||
|
"qdrant_points": qdrant_points,
|
||||||
|
"collections": {c: qdrant_store.collection_point_count(c) for c in sorted(collections)},
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Paperless → Qdrant indexing pipeline."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("loogle_mcp.indexer")
|
||||||
|
|
||||||
|
from ..db import get_conn
|
||||||
|
from . import embeddings, gitea_indexer, paperless, qdrant_store
|
||||||
|
from .text_chunk import chunk_text as _chunk_text
|
||||||
|
|
||||||
|
|
||||||
|
def index_document(doc_id: int, force: bool = False, paperless_user: Optional[str] = None) -> dict:
|
||||||
|
conn = get_conn()
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT doc_id FROM indexed_documents WHERE doc_id=?", (doc_id,)
|
||||||
|
).fetchone()
|
||||||
|
if existing and not force:
|
||||||
|
return {"doc_id": doc_id, "skipped": True}
|
||||||
|
doc = paperless.get_document(doc_id, username=paperless_user)
|
||||||
|
text = paperless.download_document_text(doc_id, username=paperless_user)
|
||||||
|
owner = paperless.document_owner(doc)
|
||||||
|
visibility = paperless.document_visibility(doc, owner)
|
||||||
|
chunks = _chunk_text(text)
|
||||||
|
if not chunks:
|
||||||
|
return {"doc_id": doc_id, "chunks": 0}
|
||||||
|
vectors = embeddings.embed_texts(chunks)
|
||||||
|
collection = qdrant_store.SHARED_COLLECTION if visibility == "family" else qdrant_store.kb_collection(owner)
|
||||||
|
qdrant_store.delete_by_doc(collection, doc_id)
|
||||||
|
ids = []
|
||||||
|
payloads = []
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
point_id = f"doc-{doc_id}-chunk-{i}"
|
||||||
|
ids.append(point_id)
|
||||||
|
payloads.append(
|
||||||
|
{
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"chunk_index": i,
|
||||||
|
"title": doc.get("title") or f"Documento {doc_id}",
|
||||||
|
"text": chunk,
|
||||||
|
"owner": owner,
|
||||||
|
"visibility": visibility,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO indexed_documents(doc_id,title,owner,visibility,chunk_count,indexed_at)"
|
||||||
|
" VALUES (?,?,?,?,?,datetime('now'))"
|
||||||
|
" ON CONFLICT(doc_id) DO UPDATE SET"
|
||||||
|
" title=excluded.title, owner=excluded.owner, visibility=excluded.visibility,"
|
||||||
|
" chunk_count=excluded.chunk_count, indexed_at=datetime('now')",
|
||||||
|
(doc_id, doc.get("title"), owner, visibility, len(chunks)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"doc_id": doc_id, "chunks": len(chunks), "collection": collection}
|
||||||
|
|
||||||
|
|
||||||
|
def index_all(max_pages: int = 20) -> dict:
|
||||||
|
indexed = 0
|
||||||
|
errors = 0
|
||||||
|
seen: set[int] = set()
|
||||||
|
users = paperless.list_configured_users() or ["daniele"]
|
||||||
|
for paperless_user in users:
|
||||||
|
page = 1
|
||||||
|
while page <= max_pages:
|
||||||
|
try:
|
||||||
|
data = paperless.list_documents(page=page, page_size=25, username=paperless_user)
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.error("Paperless list failed for %s: %s", paperless_user, exc)
|
||||||
|
break
|
||||||
|
results = data.get("results") or []
|
||||||
|
if not results:
|
||||||
|
break
|
||||||
|
for doc in results:
|
||||||
|
doc_id = doc["id"]
|
||||||
|
if doc_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(doc_id)
|
||||||
|
try:
|
||||||
|
index_document(doc_id, paperless_user=paperless_user)
|
||||||
|
indexed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Index doc %s failed (%s): %s", doc_id, paperless_user, exc)
|
||||||
|
errors += 1
|
||||||
|
if not data.get("next"):
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return {"indexed": indexed, "errors": errors, "users": users}
|
||||||
|
|
||||||
|
|
||||||
|
def index_context_snippet(
|
||||||
|
username: str,
|
||||||
|
project_id: str,
|
||||||
|
text: str,
|
||||||
|
title: str,
|
||||||
|
snippet_id: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
chunks = _chunk_text(text)
|
||||||
|
if not chunks:
|
||||||
|
return
|
||||||
|
vectors = embeddings.embed_texts(chunks)
|
||||||
|
collection = qdrant_store.ctx_collection(username)
|
||||||
|
doc_key = f"ctx-{username}-{project_id}"
|
||||||
|
if snippet_id:
|
||||||
|
doc_key = f"{doc_key}-{snippet_id}"
|
||||||
|
doc_id = hash(doc_key) % (2**31)
|
||||||
|
qdrant_store.delete_by_doc(collection, doc_id)
|
||||||
|
ids = []
|
||||||
|
payloads = []
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
point_id = f"{doc_key}-chunk-{i}"
|
||||||
|
ids.append(point_id)
|
||||||
|
payloads.append(
|
||||||
|
{
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
"chunk_index": i,
|
||||||
|
"title": title,
|
||||||
|
"text": chunk,
|
||||||
|
"owner": username,
|
||||||
|
"visibility": "personal",
|
||||||
|
"source": "context",
|
||||||
|
"snippet_id": snippet_id or "latest",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
|
||||||
|
|
||||||
|
|
||||||
|
def search_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list:
|
||||||
|
vectors = embeddings.embed_texts([query])
|
||||||
|
collections = [
|
||||||
|
qdrant_store.SHARED_COLLECTION,
|
||||||
|
qdrant_store.kb_collection(username),
|
||||||
|
qdrant_store.ctx_collection(username),
|
||||||
|
]
|
||||||
|
collections.extend(gitea_indexer.gitea_collections_for_user(username, is_admin=is_admin))
|
||||||
|
collections.append(qdrant_store.APPS_SHARED_COLLECTION)
|
||||||
|
if is_admin:
|
||||||
|
for u in ("daniele", "lucia", "davide", "luca"):
|
||||||
|
collections.append(qdrant_store.kb_collection(u))
|
||||||
|
collections = list(dict.fromkeys(collections))
|
||||||
|
hits = qdrant_store.search(collections, vectors[0], limit=limit)
|
||||||
|
for hit in hits:
|
||||||
|
if hit.get("repo") and not hit.get("source"):
|
||||||
|
hit["source"] = "gitea"
|
||||||
|
elif hit.get("source") in ("irrigazione", "turni"):
|
||||||
|
hit["source_type"] = "apps"
|
||||||
|
elif hit.get("doc_id") and not hit.get("source"):
|
||||||
|
hit["source"] = "paperless"
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def search_gitea_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list:
|
||||||
|
return gitea_indexer.search_gitea_knowledge(username, query, limit=limit, is_admin=is_admin)
|
||||||
|
|
||||||
|
|
||||||
|
def search_context(username: str, query: str, limit: int = 8) -> list:
|
||||||
|
vectors = embeddings.embed_texts([query])
|
||||||
|
return qdrant_store.search([qdrant_store.ctx_collection(username)], vectors[0], limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
def list_recent_documents(limit: int = 20) -> list:
|
||||||
|
rows = get_conn().execute(
|
||||||
|
"SELECT * FROM indexed_documents ORDER BY indexed_at DESC LIMIT ?", (limit,)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Paperless-ngx REST API client — supporto token per utente MCP."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("loogle_mcp.paperless")
|
||||||
|
|
||||||
|
PAPERLESS_URL = os.environ.get("PAPERLESS_URL", "https://docs.loogle.it").rstrip("/")
|
||||||
|
MCP_USERS = ("daniele", "lucia", "davide", "luca")
|
||||||
|
_tokens_cache: Optional[dict[str, str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tokens() -> dict[str, str]:
|
||||||
|
"""Carica token Paperless per utente MCP.
|
||||||
|
|
||||||
|
Priorità per ogni utente:
|
||||||
|
1. PAPERLESS_API_TOKEN_{USERNAME} (es. PAPERLESS_API_TOKEN_LUCIA)
|
||||||
|
2. Chiavi in PAPERLESS_API_TOKENS JSON (es. {"daniele":"...", "lucia":"..."})
|
||||||
|
3. PAPERLESS_API_TOKEN globale (fallback per tutti, tipico account admin)
|
||||||
|
"""
|
||||||
|
global _tokens_cache
|
||||||
|
if _tokens_cache is not None:
|
||||||
|
return _tokens_cache
|
||||||
|
|
||||||
|
tokens: dict[str, str] = {}
|
||||||
|
json_map = os.environ.get("PAPERLESS_API_TOKENS", "").strip()
|
||||||
|
if json_map:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(json_map)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
tokens.update({k.lower(): v for k, v in parsed.items() if v})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
LOGGER.warning("PAPERLESS_API_TOKENS non è JSON valido")
|
||||||
|
|
||||||
|
fallback = os.environ.get("PAPERLESS_API_TOKEN", "").strip()
|
||||||
|
for user in MCP_USERS:
|
||||||
|
env_key = f"PAPERLESS_API_TOKEN_{user.upper()}"
|
||||||
|
token = os.environ.get(env_key, "").strip()
|
||||||
|
if token:
|
||||||
|
tokens[user] = token
|
||||||
|
elif user not in tokens and fallback:
|
||||||
|
tokens[user] = fallback
|
||||||
|
|
||||||
|
if not tokens and fallback:
|
||||||
|
tokens["daniele"] = fallback
|
||||||
|
|
||||||
|
_tokens_cache = tokens
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def list_configured_users() -> list[str]:
|
||||||
|
return list(_load_tokens().keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(username: Optional[str] = None) -> dict:
|
||||||
|
tokens = _load_tokens()
|
||||||
|
if not tokens:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Nessun token Paperless configurato. "
|
||||||
|
"Imposta PAPERLESS_API_TOKEN o PAPERLESS_API_TOKEN_{USER} in .env"
|
||||||
|
)
|
||||||
|
user = (username or "daniele").lower()
|
||||||
|
token = tokens.get(user) or tokens.get("daniele") or next(iter(tokens.values()))
|
||||||
|
return {"Authorization": f"Token {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def list_documents(
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 25,
|
||||||
|
ordering: str = "-modified",
|
||||||
|
username: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
with httpx.Client(timeout=60.0, verify=True) as client:
|
||||||
|
resp = client.get(
|
||||||
|
f"{PAPERLESS_URL}/api/documents/",
|
||||||
|
headers=_headers(username),
|
||||||
|
params={"page": page, "page_size": page_size, "ordering": ordering},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_document(doc_id: int, username: Optional[str] = None) -> dict:
|
||||||
|
with httpx.Client(timeout=60.0, verify=True) as client:
|
||||||
|
resp = client.get(
|
||||||
|
f"{PAPERLESS_URL}/api/documents/{doc_id}/",
|
||||||
|
headers=_headers(username),
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def download_document_text(doc_id: int, username: Optional[str] = None) -> str:
|
||||||
|
doc = get_document(doc_id, username=username)
|
||||||
|
content = (doc.get("content") or "").strip()
|
||||||
|
if content:
|
||||||
|
return content
|
||||||
|
title = doc.get("title") or f"Documento {doc_id}"
|
||||||
|
return f"# {title}\n\n(Nessun testo OCR disponibile)"
|
||||||
|
|
||||||
|
|
||||||
|
def document_visibility(doc: dict, owner_username: str) -> str:
|
||||||
|
tags = doc.get("tags") or []
|
||||||
|
tag_names = []
|
||||||
|
for t in tags:
|
||||||
|
if isinstance(t, dict):
|
||||||
|
tag_names.append((t.get("name") or "").lower())
|
||||||
|
elif isinstance(t, int):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
tag_names.append(str(t).lower())
|
||||||
|
if any(t in ("personal", "privato", "private") for t in tag_names):
|
||||||
|
return "personal"
|
||||||
|
if any(t in ("admin-only", "admin") for t in tag_names):
|
||||||
|
return "admin"
|
||||||
|
return "family"
|
||||||
|
|
||||||
|
|
||||||
|
def document_owner(doc: dict, default: str = "daniele") -> str:
|
||||||
|
owner = doc.get("owner")
|
||||||
|
if isinstance(owner, int):
|
||||||
|
pass
|
||||||
|
owner_username = doc.get("owner_username") or doc.get("owner_name")
|
||||||
|
if isinstance(owner_username, str):
|
||||||
|
name = owner_username.lower()
|
||||||
|
for user in MCP_USERS:
|
||||||
|
if user in name:
|
||||||
|
return user
|
||||||
|
correspondent = doc.get("correspondent")
|
||||||
|
if isinstance(correspondent, dict):
|
||||||
|
name = (correspondent.get("name") or "").lower()
|
||||||
|
for user in MCP_USERS:
|
||||||
|
if user in name:
|
||||||
|
return user
|
||||||
|
return default
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Vector store — Qdrant remoto (DS920) con fallback SQLite locale su Pi ARM."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("loogle_mcp.vector_store")
|
||||||
|
VECTOR_SIZE = 768
|
||||||
|
_local = sqlite3.connect(":memory:", check_same_thread=False) # placeholder
|
||||||
|
_qdrant_client = None
|
||||||
|
_qdrant_checked = False
|
||||||
|
_use_fallback = False
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_path() -> str:
|
||||||
|
return os.environ.get("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_conn() -> sqlite3.Connection:
|
||||||
|
path = _fallback_path()
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(path, timeout=30)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS vectors (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
vector TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_vectors_collection ON vectors(collection)")
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _get_qdrant():
|
||||||
|
global _qdrant_client, _qdrant_checked, _use_fallback
|
||||||
|
if _qdrant_checked:
|
||||||
|
return None if _use_fallback else _qdrant_client
|
||||||
|
_qdrant_checked = True
|
||||||
|
url = os.environ.get("QDRANT_URL", "").strip()
|
||||||
|
if not url:
|
||||||
|
_use_fallback = True
|
||||||
|
LOGGER.warning("QDRANT_URL non impostato — fallback SQLite")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
|
||||||
|
client = QdrantClient(url=url, timeout=60)
|
||||||
|
client.get_collections()
|
||||||
|
_qdrant_client = client
|
||||||
|
globals()["qm"] = qm
|
||||||
|
LOGGER.info("Qdrant connesso: %s", url)
|
||||||
|
return client
|
||||||
|
except Exception as exc:
|
||||||
|
_use_fallback = True
|
||||||
|
LOGGER.warning("Qdrant non disponibile (%s) — fallback SQLite", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_collection(name: str, vector_size: int = VECTOR_SIZE) -> None:
|
||||||
|
client = _get_qdrant()
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
|
||||||
|
names = {c.name for c in client.get_collections().collections}
|
||||||
|
if name in names:
|
||||||
|
return
|
||||||
|
client.create_collection(
|
||||||
|
collection_name=name,
|
||||||
|
vectors_config=qm.VectorParams(size=vector_size, distance=qm.Distance.COSINE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def kb_collection(username: str) -> str:
|
||||||
|
return f"kb_personal_{username}"
|
||||||
|
|
||||||
|
|
||||||
|
def ctx_collection(username: str) -> str:
|
||||||
|
return f"ctx_{username}"
|
||||||
|
|
||||||
|
|
||||||
|
SHARED_COLLECTION = "kb_shared_family"
|
||||||
|
GITEA_SHARED_COLLECTION = "gitea_shared_family"
|
||||||
|
APPS_SHARED_COLLECTION = "apps_shared_family"
|
||||||
|
|
||||||
|
|
||||||
|
def gitea_collection(username: str) -> str:
|
||||||
|
return f"gitea_personal_{username}"
|
||||||
|
|
||||||
|
|
||||||
|
def _point_id(name: str) -> str:
|
||||||
|
return str(uuid.uuid5(uuid.NAMESPACE_URL, name))
|
||||||
|
|
||||||
|
|
||||||
|
def _cosine(a: list[float], b: list[float]) -> float:
|
||||||
|
dot = sum(x * y for x, y in zip(a, b))
|
||||||
|
na = math.sqrt(sum(x * x for x in a)) or 1.0
|
||||||
|
nb = math.sqrt(sum(x * x for x in b)) or 1.0
|
||||||
|
return dot / (na * nb)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_chunks(
|
||||||
|
collection: str,
|
||||||
|
ids: list[str],
|
||||||
|
vectors: list[list[float]],
|
||||||
|
payloads: list[dict],
|
||||||
|
) -> None:
|
||||||
|
if not vectors:
|
||||||
|
return
|
||||||
|
ensure_collection(collection, len(vectors[0]))
|
||||||
|
client = _get_qdrant()
|
||||||
|
if client is not None:
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
|
||||||
|
points = [
|
||||||
|
qm.PointStruct(id=_point_id(pid), vector=vec, payload=payload)
|
||||||
|
for pid, vec, payload in zip(ids, vectors, payloads)
|
||||||
|
]
|
||||||
|
client.upsert(collection_name=collection, points=points)
|
||||||
|
return
|
||||||
|
conn = _fallback_conn()
|
||||||
|
for pid, vec, payload in zip(ids, vectors, payloads):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO vectors(id,collection,vector,payload) VALUES (?,?,?,?)",
|
||||||
|
(_point_id(pid), collection, json.dumps(vec), json.dumps(payload, ensure_ascii=False)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_by_doc(collection: str, doc_id: int) -> None:
|
||||||
|
client = _get_qdrant()
|
||||||
|
if client is not None:
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
|
||||||
|
ensure_collection(collection)
|
||||||
|
client.delete(
|
||||||
|
collection_name=collection,
|
||||||
|
points_selector=qm.FilterSelector(
|
||||||
|
filter=qm.Filter(
|
||||||
|
must=[qm.FieldCondition(key="doc_id", match=qm.MatchValue(value=doc_id))]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
conn = _fallback_conn()
|
||||||
|
rows = conn.execute("SELECT id,payload FROM vectors WHERE collection=?", (collection,)).fetchall()
|
||||||
|
for row_id, payload_raw in rows:
|
||||||
|
payload = json.loads(payload_raw)
|
||||||
|
if payload.get("doc_id") == doc_id:
|
||||||
|
conn.execute("DELETE FROM vectors WHERE id=?", (row_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def count_by_doc(collection: str, doc_id: int) -> int:
|
||||||
|
client = _get_qdrant()
|
||||||
|
if client is not None:
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
|
||||||
|
ensure_collection(collection)
|
||||||
|
result = client.count(
|
||||||
|
collection_name=collection,
|
||||||
|
count_filter=qm.Filter(
|
||||||
|
must=[qm.FieldCondition(key="doc_id", match=qm.MatchValue(value=doc_id))]
|
||||||
|
),
|
||||||
|
exact=True,
|
||||||
|
)
|
||||||
|
return int(result.count)
|
||||||
|
conn = _fallback_conn()
|
||||||
|
rows = conn.execute("SELECT payload FROM vectors WHERE collection=?", (collection,)).fetchall()
|
||||||
|
count = 0
|
||||||
|
for (payload_raw,) in rows:
|
||||||
|
payload = json.loads(payload_raw)
|
||||||
|
if payload.get("doc_id") == doc_id:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def collection_point_count(collection: str) -> int:
|
||||||
|
client = _get_qdrant()
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
info = client.get_collection(collection)
|
||||||
|
return int(info.points_count or 0)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
conn = _fallback_conn()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM vectors WHERE collection=?", (collection,)
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0] if row else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def search(
|
||||||
|
collections: list[str],
|
||||||
|
vector: list[float],
|
||||||
|
limit: int = 8,
|
||||||
|
visibility_filter: Optional[dict] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
results: list[dict] = []
|
||||||
|
client = _get_qdrant()
|
||||||
|
if client is not None:
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
|
||||||
|
for collection in collections:
|
||||||
|
ensure_collection(collection, len(vector))
|
||||||
|
flt = None
|
||||||
|
if visibility_filter:
|
||||||
|
must = [
|
||||||
|
qm.FieldCondition(key=k, match=qm.MatchValue(value=v))
|
||||||
|
for k, v in visibility_filter.items()
|
||||||
|
]
|
||||||
|
if must:
|
||||||
|
flt = qm.Filter(must=must)
|
||||||
|
hits = client.search(
|
||||||
|
collection_name=collection,
|
||||||
|
query_vector=vector,
|
||||||
|
limit=limit,
|
||||||
|
query_filter=flt,
|
||||||
|
)
|
||||||
|
for hit in hits:
|
||||||
|
payload = dict(hit.payload or {})
|
||||||
|
payload["score"] = hit.score
|
||||||
|
payload["collection"] = collection
|
||||||
|
results.append(payload)
|
||||||
|
else:
|
||||||
|
conn = _fallback_conn()
|
||||||
|
for collection in collections:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT vector,payload FROM vectors WHERE collection=?", (collection,)
|
||||||
|
).fetchall()
|
||||||
|
for vec_raw, payload_raw in rows:
|
||||||
|
payload = dict(json.loads(payload_raw))
|
||||||
|
if visibility_filter:
|
||||||
|
if any(payload.get(k) != v for k, v in visibility_filter.items()):
|
||||||
|
continue
|
||||||
|
score = _cosine(vector, json.loads(vec_raw))
|
||||||
|
payload["score"] = score
|
||||||
|
payload["collection"] = collection
|
||||||
|
results.append(payload)
|
||||||
|
results.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||||
|
return results[:limit]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Utility condivise per chunking testo RAG."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
CHUNK_SIZE = 900
|
||||||
|
CHUNK_OVERLAP = 150
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text(text: str) -> list[str]:
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text.strip())
|
||||||
|
if len(text) <= CHUNK_SIZE:
|
||||||
|
return [text] if text else []
|
||||||
|
chunks = []
|
||||||
|
start = 0
|
||||||
|
while start < len(text):
|
||||||
|
end = min(len(text), start + CHUNK_SIZE)
|
||||||
|
chunks.append(text[start:end])
|
||||||
|
if end >= len(text):
|
||||||
|
break
|
||||||
|
start = max(end - CHUNK_OVERLAP, start + 1)
|
||||||
|
return chunks
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Thermal / load gate per proteggere il DS920 durante gli embedding Ollama.
|
||||||
|
|
||||||
|
Legge temperatura CPU e load average dal NAS e regola pause/keep_alive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("loogle_mcp.thermal")
|
||||||
|
|
||||||
|
# Soft: rallenta. Hard: pausa (raro se il profilo lento tiene).
|
||||||
|
# Profilo "lento ma regolare": anticipare soft, cap load basso.
|
||||||
|
DEFAULT_SOFT_C = 58.0
|
||||||
|
DEFAULT_HARD_C = 70.0
|
||||||
|
DEFAULT_CPU_TARGET_PCT = 40.0 # load1 <= nproc * 0.40
|
||||||
|
|
||||||
|
|
||||||
|
def _float_env(name: str, default: float) -> float:
|
||||||
|
try:
|
||||||
|
return float(os.environ.get(name, str(default)))
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def soft_temp_c() -> float:
|
||||||
|
return _float_env("THERMAL_TEMP_SOFT_C", DEFAULT_SOFT_C)
|
||||||
|
|
||||||
|
|
||||||
|
def hard_temp_c() -> float:
|
||||||
|
return _float_env("THERMAL_TEMP_HARD_C", DEFAULT_HARD_C)
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_target_pct() -> float:
|
||||||
|
return _float_env("THERMAL_CPU_TARGET_PCT", DEFAULT_CPU_TARGET_PCT)
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
flag = os.environ.get("THERMAL_GATE_ENABLED", "yes").strip().lower()
|
||||||
|
return flag not in ("0", "false", "no", "off")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_via_http() -> Optional[dict]:
|
||||||
|
url = os.environ.get("DS920_THERMAL_URL", "").strip()
|
||||||
|
if not url:
|
||||||
|
# default probe se non configurato
|
||||||
|
url = os.environ.get(
|
||||||
|
"DS920_THERMAL_URL_DEFAULT",
|
||||||
|
"http://192.168.128.100:9191/thermal",
|
||||||
|
).strip()
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=3.0) as client:
|
||||||
|
resp = client.get(url)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
if isinstance(data, dict) and "cpu_temp_c" in data:
|
||||||
|
return data
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.debug("Thermal HTTP probe fallita: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_via_ssh() -> Optional[dict]:
|
||||||
|
host = os.environ.get("DS920_SSH_HOST", "192.168.128.100").strip()
|
||||||
|
user = os.environ.get("DS920_SSH_USER", "daniely").strip()
|
||||||
|
key = os.environ.get("DS920_SSH_KEY", "").strip()
|
||||||
|
if not host:
|
||||||
|
return None
|
||||||
|
remote = (
|
||||||
|
"python3 -c \"import json,os;"
|
||||||
|
"b='/sys/class/hwmon/hwmon0';"
|
||||||
|
"t=[int(open(f'{b}/'+n).read())/1000 for n in sorted(os.listdir(b)) "
|
||||||
|
"if n.startswith('temp') and n.endswith('_input')];"
|
||||||
|
"l=os.getloadavg();"
|
||||||
|
"print(json.dumps({'cpu_temp_c':max(t) if t else None,"
|
||||||
|
"'load1':l[0],'load5':l[1],'nproc':os.cpu_count() or 4}))\""
|
||||||
|
)
|
||||||
|
cmd = [
|
||||||
|
"ssh",
|
||||||
|
"-o", "BatchMode=yes",
|
||||||
|
"-o", "ConnectTimeout=5",
|
||||||
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
]
|
||||||
|
if key and os.path.isfile(key):
|
||||||
|
cmd.extend(["-i", key])
|
||||||
|
cmd.append(f"{user}@{host}")
|
||||||
|
cmd.append(remote)
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, timeout=12, text=True)
|
||||||
|
data = json.loads(out.strip())
|
||||||
|
if isinstance(data, dict) and data.get("cpu_temp_c") is not None:
|
||||||
|
return data
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.debug("Thermal SSH probe fallita: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_status() -> Optional[dict]:
|
||||||
|
"""Ritorna {cpu_temp_c, load1, load5?, nproc} oppure None se non raggiungibile."""
|
||||||
|
data = _read_via_http()
|
||||||
|
if data:
|
||||||
|
data["source"] = "http"
|
||||||
|
return data
|
||||||
|
data = _read_via_ssh()
|
||||||
|
if data:
|
||||||
|
data["source"] = "ssh"
|
||||||
|
return data
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_over_target(status: dict) -> bool:
|
||||||
|
load1 = float(status.get("load1") or 0)
|
||||||
|
nproc = float(status.get("nproc") or 4)
|
||||||
|
target = nproc * (cpu_target_pct() / 100.0)
|
||||||
|
return load1 > target
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_keep_alive(status: Optional[dict]) -> int:
|
||||||
|
"""Secondi keep_alive Ollama.
|
||||||
|
|
||||||
|
Il container resta sempre acceso: non si fa unload per throttling termico
|
||||||
|
(evita cicli load/unload). Si scarica solo se OLLAMA_UNLOAD_ON_HARD=yes.
|
||||||
|
"""
|
||||||
|
cool = int(_float_env("OLLAMA_KEEP_ALIVE_COOL", 300))
|
||||||
|
default = int(_float_env("OLLAMA_KEEP_ALIVE_DEFAULT", 120))
|
||||||
|
if not status or status.get("cpu_temp_c") is None:
|
||||||
|
return default
|
||||||
|
temp = float(status["cpu_temp_c"])
|
||||||
|
unload = os.environ.get("OLLAMA_UNLOAD_ON_HARD", "no").strip().lower()
|
||||||
|
if temp >= hard_temp_c() and unload in ("1", "true", "yes", "on"):
|
||||||
|
return 0
|
||||||
|
return cool
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_delay_s(status: Optional[dict]) -> float:
|
||||||
|
"""Duty-cycle lento: delay base sempre presente; cresce con temp/load."""
|
||||||
|
base = _float_env("OLLAMA_EMBED_DELAY_S", 8.0)
|
||||||
|
if not status or status.get("cpu_temp_c") is None:
|
||||||
|
return max(base, 5.0)
|
||||||
|
temp = float(status["cpu_temp_c"])
|
||||||
|
hard = hard_temp_c()
|
||||||
|
soft = soft_temp_c()
|
||||||
|
if temp >= hard:
|
||||||
|
return max(base, 45.0)
|
||||||
|
if temp >= soft:
|
||||||
|
# soft→hard: ~base*1.5 … ~35s (continuo, non on/off)
|
||||||
|
ratio = (temp - soft) / max(hard - soft, 1.0)
|
||||||
|
return max(base, base * 1.5 + ratio * 25.0)
|
||||||
|
if load_over_target(status):
|
||||||
|
return max(base, base * 2.0)
|
||||||
|
if temp >= soft - 4:
|
||||||
|
return max(base, base * 1.25)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_headroom(*, context: str = "embed") -> Optional[dict]:
|
||||||
|
"""Attende headroom: HARD = pausa lunga; altrimenti delay proporzionale.
|
||||||
|
|
||||||
|
Obiettivo: ritmo lento e regolare, evitando oscillazioni start/stop.
|
||||||
|
"""
|
||||||
|
if not enabled():
|
||||||
|
return None
|
||||||
|
|
||||||
|
poll = _float_env("THERMAL_POLL_S", 30.0)
|
||||||
|
hard = hard_temp_c()
|
||||||
|
soft = soft_temp_c()
|
||||||
|
# Riprendi solo quando sotto soft - 2°C (isteresi anti-oscillazione)
|
||||||
|
resume_below = soft - _float_env("THERMAL_RESUME_MARGIN_C", 2.0)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
status = read_status()
|
||||||
|
if status is None:
|
||||||
|
LOGGER.warning("Thermal gate: probe non disponibile — delay conservativo")
|
||||||
|
time.sleep(max(suggested_delay_s(None), 5.0))
|
||||||
|
return None
|
||||||
|
|
||||||
|
temp = float(status.get("cpu_temp_c") or 0)
|
||||||
|
load1 = float(status.get("load1") or 0)
|
||||||
|
|
||||||
|
if temp >= hard:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Thermal gate [%s]: PAUSA HARD temp=%.1f°C (tetto=%.0f°C resume<=%.0f°C "
|
||||||
|
"load1=%.2f) — riprovo tra %.0fs",
|
||||||
|
context,
|
||||||
|
temp,
|
||||||
|
hard,
|
||||||
|
resume_below,
|
||||||
|
load1,
|
||||||
|
poll,
|
||||||
|
)
|
||||||
|
time.sleep(poll)
|
||||||
|
# Isteresi: resta in pausa finché non scende sotto soft
|
||||||
|
while True:
|
||||||
|
cooled = read_status()
|
||||||
|
if cooled is None:
|
||||||
|
time.sleep(poll)
|
||||||
|
continue
|
||||||
|
t2 = float(cooled.get("cpu_temp_c") or 0)
|
||||||
|
if t2 <= resume_below and not load_over_target(cooled):
|
||||||
|
LOGGER.info(
|
||||||
|
"Thermal gate [%s]: ripresa dopo HARD (temp=%.1f°C)",
|
||||||
|
context,
|
||||||
|
t2,
|
||||||
|
)
|
||||||
|
status = cooled
|
||||||
|
break
|
||||||
|
time.sleep(poll)
|
||||||
|
# dopo ripresa applica comunque un delay soft prima dell'embed
|
||||||
|
time.sleep(suggested_delay_s(status))
|
||||||
|
return status
|
||||||
|
|
||||||
|
if temp >= soft or load_over_target(status):
|
||||||
|
delay = suggested_delay_s(status)
|
||||||
|
LOGGER.info(
|
||||||
|
"Thermal gate [%s]: rallento temp=%.1f°C load1=%.2f — delay %.1fs (source=%s)",
|
||||||
|
context,
|
||||||
|
temp,
|
||||||
|
load1,
|
||||||
|
delay,
|
||||||
|
status.get("source"),
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
|
again = read_status()
|
||||||
|
if again and float(again.get("cpu_temp_c") or 0) >= hard:
|
||||||
|
status = again
|
||||||
|
continue
|
||||||
|
return again or status
|
||||||
|
|
||||||
|
# Zona fredda: piccolo delay fisso per duty-cycle regolare
|
||||||
|
cool_delay = _float_env("OLLAMA_EMBED_COOL_DELAY_S", 0.0)
|
||||||
|
if cool_delay > 0:
|
||||||
|
time.sleep(cool_delay)
|
||||||
|
return status
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Loogle MCP Hub — gateway FastAPI + OAuth + MCP Streamable HTTP."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from . import audit, auth, jwt_utils, oauth
|
||||||
|
from .db import get_conn, init_db
|
||||||
|
from .mcp import server as mcp_server
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||||
|
LOGGER = logging.getLogger("loogle_mcp.main")
|
||||||
|
|
||||||
|
app = FastAPI(title="Loogle MCP Hub", docs_url=None, redoc_url=None)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[
|
||||||
|
"https://claude.ai",
|
||||||
|
"https://chatgpt.com",
|
||||||
|
"https://chat.openai.com",
|
||||||
|
],
|
||||||
|
allow_methods=["GET", "POST", "OPTIONS"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup() -> None:
|
||||||
|
init_db()
|
||||||
|
auth.ensure_sessions_table()
|
||||||
|
auth.ensure_family_users()
|
||||||
|
oauth.ensure_default_client()
|
||||||
|
if not os.environ.get("MCP_JWT_SECRET", "").strip():
|
||||||
|
secret = secrets.token_urlsafe(48)
|
||||||
|
os.environ["MCP_JWT_SECRET"] = secret
|
||||||
|
LOGGER.warning("MCP_JWT_SECRET generato — salvalo in .env: %s", secret)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Health
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True, "service": "loogle-mcp", "port": int(os.environ.get("MCP_PORT", "8700"))}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ OAuth metadata
|
||||||
|
|
||||||
|
@app.get("/.well-known/oauth-authorization-server")
|
||||||
|
def oauth_metadata():
|
||||||
|
return oauth.authorization_server_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/.well-known/oauth-protected-resource")
|
||||||
|
def protected_resource():
|
||||||
|
return oauth.protected_resource_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/.well-known/oauth-protected-resource/mcp")
|
||||||
|
def protected_resource_mcp():
|
||||||
|
return oauth.protected_resource_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/.well-known/openid-configuration")
|
||||||
|
def openid_configuration():
|
||||||
|
"""Fallback discovery usato da Claude se oauth-authorization-server non basta."""
|
||||||
|
return oauth.authorization_server_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ OAuth endpoints
|
||||||
|
|
||||||
|
class RegisterBody(BaseModel):
|
||||||
|
client_name: str
|
||||||
|
redirect_uris: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/oauth/register")
|
||||||
|
def oauth_register(body: RegisterBody):
|
||||||
|
try:
|
||||||
|
return oauth.register_client(body.client_name, body.redirect_uris)
|
||||||
|
except HTTPException as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={"error": "invalid_client_metadata", "error_description": str(exc.detail)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Alias root-level OAuth (Claude/ChatGPT fallback se la discovery RFC 8414 fallisce)
|
||||||
|
@app.post("/register")
|
||||||
|
def oauth_register_root(body: RegisterBody):
|
||||||
|
return oauth_register(body)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/oauth/authorize")
|
||||||
|
def oauth_authorize_get(
|
||||||
|
response_type: str,
|
||||||
|
client_id: str,
|
||||||
|
redirect_uri: str,
|
||||||
|
scope: str = "context:read context:write knowledge:read knowledge:write gitea:read gitea:write",
|
||||||
|
state: str = "",
|
||||||
|
code_challenge: Optional[str] = None,
|
||||||
|
code_challenge_method: Optional[str] = None,
|
||||||
|
):
|
||||||
|
if response_type != "code":
|
||||||
|
raise HTTPException(400, "response_type must be code")
|
||||||
|
html = _login_form(client_id, redirect_uri, scope, state, code_challenge, code_challenge_method)
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/oauth/authorize")
|
||||||
|
def oauth_authorize_post(
|
||||||
|
request: Request,
|
||||||
|
client_id: str = Form(...),
|
||||||
|
redirect_uri: str = Form(...),
|
||||||
|
scope: str = Form("context:read context:write knowledge:read knowledge:write gitea:read gitea:write"),
|
||||||
|
state: str = Form(""),
|
||||||
|
code_challenge: Optional[str] = Form(None),
|
||||||
|
code_challenge_method: Optional[str] = Form(None),
|
||||||
|
username: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
):
|
||||||
|
ip = request.client.host if request.client else "?"
|
||||||
|
auth.throttle(ip)
|
||||||
|
user = auth.authenticate(username, password)
|
||||||
|
if not user:
|
||||||
|
auth.record_attempt(ip)
|
||||||
|
html = _login_form(
|
||||||
|
client_id, redirect_uri, scope, state, code_challenge, code_challenge_method,
|
||||||
|
error="Credenziali non valide",
|
||||||
|
)
|
||||||
|
return HTMLResponse(html, status_code=401)
|
||||||
|
url = oauth.build_authorize_redirect(
|
||||||
|
client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, user["id"]
|
||||||
|
)
|
||||||
|
return RedirectResponse(url, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/authorize")
|
||||||
|
def oauth_authorize_get_root(
|
||||||
|
response_type: str,
|
||||||
|
client_id: str,
|
||||||
|
redirect_uri: str,
|
||||||
|
scope: str = "context:read context:write knowledge:read knowledge:write gitea:read gitea:write",
|
||||||
|
state: str = "",
|
||||||
|
code_challenge: Optional[str] = None,
|
||||||
|
code_challenge_method: Optional[str] = None,
|
||||||
|
):
|
||||||
|
return oauth_authorize_get(
|
||||||
|
response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/authorize")
|
||||||
|
def oauth_authorize_post_root(
|
||||||
|
request: Request,
|
||||||
|
client_id: str = Form(...),
|
||||||
|
redirect_uri: str = Form(...),
|
||||||
|
scope: str = Form("context:read context:write knowledge:read knowledge:write gitea:read gitea:write"),
|
||||||
|
state: str = Form(""),
|
||||||
|
code_challenge: Optional[str] = Form(None),
|
||||||
|
code_challenge_method: Optional[str] = Form(None),
|
||||||
|
username: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
):
|
||||||
|
return oauth_authorize_post(
|
||||||
|
request, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, username, password
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/oauth/token")
|
||||||
|
async def oauth_token(request: Request):
|
||||||
|
content_type = request.headers.get("content-type", "")
|
||||||
|
if "application/json" in content_type:
|
||||||
|
body = await request.json()
|
||||||
|
else:
|
||||||
|
form = await request.form()
|
||||||
|
body = dict(form)
|
||||||
|
grant_type = body.get("grant_type")
|
||||||
|
client_id = body.get("client_id") or os.environ.get("MCP_OAUTH_CLIENT_ID", "loogle-mcp-public")
|
||||||
|
if grant_type == "authorization_code":
|
||||||
|
return oauth.exchange_code(
|
||||||
|
body.get("code", ""),
|
||||||
|
client_id,
|
||||||
|
body.get("redirect_uri", ""),
|
||||||
|
body.get("code_verifier"),
|
||||||
|
)
|
||||||
|
if grant_type == "refresh_token":
|
||||||
|
return oauth.refresh_access_token(body.get("refresh_token", ""), client_id)
|
||||||
|
raise HTTPException(400, "grant_type non supportato")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/token")
|
||||||
|
async def oauth_token_root(request: Request):
|
||||||
|
return await oauth_token(request)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ MCP endpoint
|
||||||
|
|
||||||
|
@app.post("/mcp")
|
||||||
|
async def mcp_post(request: Request):
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
claims = oauth.bearer_claims_from_header(auth_header)
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(400, "JSON non valido")
|
||||||
|
if isinstance(payload, list):
|
||||||
|
responses = mcp_server.handle_batch(payload, claims)
|
||||||
|
return JSONResponse(responses)
|
||||||
|
response = mcp_server.handle_message(payload, claims)
|
||||||
|
if not claims and payload.get("method") not in ("initialize", "notifications/initialized", "ping"):
|
||||||
|
return JSONResponse(response, status_code=401, headers=_auth_challenge_headers())
|
||||||
|
return JSONResponse(response)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/mcp")
|
||||||
|
def mcp_get():
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Use POST for MCP JSON-RPC"},
|
||||||
|
status_code=405,
|
||||||
|
headers=_auth_challenge_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_challenge_headers() -> dict:
|
||||||
|
base = jwt_utils.base_url()
|
||||||
|
resource_metadata = f"{base}/.well-known/oauth-protected-resource/mcp"
|
||||||
|
return {
|
||||||
|
"WWW-Authenticate": f'Bearer realm="mcp", resource_metadata="{resource_metadata}"',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Dashboard web
|
||||||
|
|
||||||
|
class LoginBody(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordBody(BaseModel):
|
||||||
|
old_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class RevokeBody(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/login")
|
||||||
|
def api_login(body: LoginBody, request: Request, response: Response):
|
||||||
|
ip = request.client.host if request.client else "?"
|
||||||
|
auth.throttle(ip)
|
||||||
|
user = auth.authenticate(body.username.strip(), body.password)
|
||||||
|
if not user:
|
||||||
|
auth.record_attempt(ip)
|
||||||
|
raise HTTPException(401, "Credenziali non valide")
|
||||||
|
import datetime
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
expires = (
|
||||||
|
datetime.datetime.utcnow() + datetime.timedelta(days=auth.SESSION_DAYS)
|
||||||
|
).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
get_conn().execute(
|
||||||
|
"INSERT INTO sessions(token,user_id,expires_at) VALUES (?,?,?)",
|
||||||
|
(token, user["id"], expires),
|
||||||
|
)
|
||||||
|
get_conn().commit()
|
||||||
|
response.set_cookie("mcp_session", token, max_age=auth.SESSION_DAYS * 86400, httponly=True, samesite="lax", path="/")
|
||||||
|
return {"ok": True, "user": {"username": user["username"], "is_admin": user["is_admin"]}}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/logout")
|
||||||
|
def api_logout(request: Request, response: Response):
|
||||||
|
token = request.cookies.get("mcp_session", "")
|
||||||
|
if token:
|
||||||
|
get_conn().execute("DELETE FROM sessions WHERE token=?", (token,))
|
||||||
|
get_conn().commit()
|
||||||
|
response.delete_cookie("mcp_session", path="/")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/me")
|
||||||
|
def api_me(user=Depends(auth.current_user_from_cookie)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/password")
|
||||||
|
def api_password(body: PasswordBody, user=Depends(auth.current_user_from_cookie)):
|
||||||
|
if len(body.new_password.strip()) < 6:
|
||||||
|
raise HTTPException(400, "La nuova password deve avere almeno 6 caratteri")
|
||||||
|
if not auth.change_password(user["id"], body.old_password, body.new_password):
|
||||||
|
raise HTTPException(400, "Password attuale errata")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/projects")
|
||||||
|
def api_projects(user=Depends(auth.current_user_from_cookie)):
|
||||||
|
from .context import store as context_store
|
||||||
|
return context_store.list_projects(user["username"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/audit")
|
||||||
|
def api_audit(limit: int = 100, user=Depends(auth.current_user_from_cookie)):
|
||||||
|
if user["is_admin"]:
|
||||||
|
return audit.list_audit(limit=min(limit, 500))
|
||||||
|
return audit.list_audit(limit=min(limit, 200), username=user["username"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/revoke-refresh")
|
||||||
|
def api_revoke_refresh(body: RevokeBody, _=Depends(auth.require_admin)):
|
||||||
|
jwt_utils.revoke_refresh_token(body.refresh_token)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/dashboard")
|
||||||
|
def dashboard_page():
|
||||||
|
path = os.path.join(STATIC_DIR, "dashboard.html")
|
||||||
|
return HTMLResponse(open(path, encoding="utf-8").read())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def root():
|
||||||
|
return RedirectResponse("/dashboard")
|
||||||
|
|
||||||
|
|
||||||
|
def _login_form(
|
||||||
|
client_id: str,
|
||||||
|
redirect_uri: str,
|
||||||
|
scope: str,
|
||||||
|
state: str,
|
||||||
|
code_challenge: Optional[str],
|
||||||
|
code_challenge_method: Optional[str],
|
||||||
|
error: str = "",
|
||||||
|
) -> str:
|
||||||
|
err = f'<p class="error">{error}</p>' if error else ""
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Loogle MCP — Login</title>
|
||||||
|
<style>
|
||||||
|
body{{font-family:system-ui,sans-serif;max-width:420px;margin:4rem auto;padding:1rem;background:#0f172a;color:#e2e8f0}}
|
||||||
|
h1{{font-size:1.4rem}} .card{{background:#1e293b;padding:1.5rem;border-radius:12px}}
|
||||||
|
label{{display:block;margin:.75rem 0 .25rem}} input{{width:100%;padding:.5rem;border-radius:6px;border:1px solid #334155;background:#0f172a;color:#e2e8f0}}
|
||||||
|
button{{margin-top:1rem;width:100%;padding:.65rem;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer}}
|
||||||
|
.error{{color:#f87171}} .hint{{font-size:.85rem;color:#94a3b8;margin-top:1rem}}
|
||||||
|
</style></head><body>
|
||||||
|
<h1>Loogle MCP Hub</h1>
|
||||||
|
<p>Accedi con le credenziali famiglia per collegare Claude, ChatGPT o Gemini.</p>
|
||||||
|
<div class="card">{err}
|
||||||
|
<form method="post" action="/authorize">
|
||||||
|
<input type="hidden" name="client_id" value="{client_id}">
|
||||||
|
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
|
||||||
|
<input type="hidden" name="scope" value="{scope}">
|
||||||
|
<input type="hidden" name="state" value="{state}">
|
||||||
|
<input type="hidden" name="code_challenge" value="{code_challenge or ''}">
|
||||||
|
<input type="hidden" name="code_challenge_method" value="{code_challenge_method or ''}">
|
||||||
|
<label>Utente</label><input name="username" autocomplete="username" required>
|
||||||
|
<label>Password</label><input name="password" type="password" autocomplete="current-password" required>
|
||||||
|
<button type="submit">Autorizza accesso MCP</button>
|
||||||
|
</form>
|
||||||
|
<p class="hint">Primo accesso: password = username (es. lucia/lucia). Cambiala dal dashboard.</p>
|
||||||
|
</div></body></html>"""
|
||||||
Whitespace-only changes.
@@ -0,0 +1,78 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""MCP JSON-RPC handler (Streamable HTTP compatible)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from . import tools
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("loogle_mcp.server")
|
||||||
|
PROTOCOL_VERSION = "2024-11-05"
|
||||||
|
|
||||||
|
|
||||||
|
def _error(req_id: Any, code: int, message: str) -> dict:
|
||||||
|
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
||||||
|
|
||||||
|
|
||||||
|
def _result(req_id: Any, result: dict) -> dict:
|
||||||
|
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_message(body: dict, claims: Optional[dict]) -> dict:
|
||||||
|
method = body.get("method")
|
||||||
|
req_id = body.get("id")
|
||||||
|
params = body.get("params") or {}
|
||||||
|
|
||||||
|
if method == "initialize":
|
||||||
|
return _result(
|
||||||
|
req_id,
|
||||||
|
{
|
||||||
|
"protocolVersion": PROTOCOL_VERSION,
|
||||||
|
"capabilities": {"tools": {}, "resources": {}},
|
||||||
|
"serverInfo": {"name": "loogle-mcp", "version": "1.0.0"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if method == "notifications/initialized":
|
||||||
|
return _result(req_id, {})
|
||||||
|
|
||||||
|
if method == "ping":
|
||||||
|
return _result(req_id, {})
|
||||||
|
|
||||||
|
if not claims:
|
||||||
|
return _error(req_id, -32001, "Autenticazione richiesta (Bearer token OAuth)")
|
||||||
|
|
||||||
|
if method == "tools/list":
|
||||||
|
return _result(req_id, {"tools": tools.tool_definitions()})
|
||||||
|
|
||||||
|
if method == "tools/call":
|
||||||
|
name = params.get("name")
|
||||||
|
arguments = params.get("arguments") or {}
|
||||||
|
try:
|
||||||
|
tool_result = tools.call_tool(name, arguments, claims)
|
||||||
|
return _result(req_id, tool_result)
|
||||||
|
except PermissionError as exc:
|
||||||
|
return _error(req_id, -32003, str(exc))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
return _error(req_id, -32004, str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.exception("Tool %s failed", name)
|
||||||
|
return _error(req_id, -32000, str(exc))
|
||||||
|
|
||||||
|
if method == "resources/list":
|
||||||
|
return _result(req_id, {"resources": tools.list_resources(claims)})
|
||||||
|
|
||||||
|
if method == "resources/read":
|
||||||
|
uri = params.get("uri")
|
||||||
|
try:
|
||||||
|
resource = tools.read_resource(uri, claims)
|
||||||
|
return _result(req_id, {"contents": [resource]})
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
return _error(req_id, -32004, str(exc))
|
||||||
|
|
||||||
|
return _error(req_id, -32601, f"Metodo non supportato: {method}")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_batch(messages: list, claims: Optional[dict]) -> list:
|
||||||
|
return [handle_message(msg, claims) for msg in messages if isinstance(msg, dict)]
|
||||||
@@ -0,0 +1,962 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""MCP tool definitions and handlers."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from .. import audit
|
||||||
|
from ..context import store as context_store
|
||||||
|
from ..jwt_utils import has_scope
|
||||||
|
from ..knowledge import apps_indexer, gitea, gitea_indexer, indexer, paperless
|
||||||
|
from ..integrations import casa, homeassistant, irrigazione, turni
|
||||||
|
|
||||||
|
|
||||||
|
def tool_definitions() -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "ping",
|
||||||
|
"description": "Verifica che il server MCP Loogle risponda",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "whoami",
|
||||||
|
"description": "Restituisce l'utente autenticato e gli scope attivi",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_projects",
|
||||||
|
"description": "Elenca i progetti dell'utente corrente",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"include_archived": {"type": "boolean", "default": False}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_project",
|
||||||
|
"description": "Crea un nuovo progetto con archivio contesto; opzionalmente collega un repo Gitea",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["title"],
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"tags": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"gitea_repo": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Repository Gitea owner/name (es. daniele/rete)",
|
||||||
|
},
|
||||||
|
"seed_from_gitea": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"description": "Importa README.md nel context.md se collegato a Gitea",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "link_project_repo",
|
||||||
|
"description": "Collega o scollega un repository Gitea da un progetto esistente",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["project_id"],
|
||||||
|
"properties": {
|
||||||
|
"project_id": {"type": "string"},
|
||||||
|
"gitea_repo": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "owner/name da collegare; omit o stringa vuota per scollegare",
|
||||||
|
},
|
||||||
|
"seed_from_gitea": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"description": "Importa README.md nel context.md (solo se non già importato)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_project_context",
|
||||||
|
"description": "Recupera meta, context.md, sessioni recenti e arricchimento Gitea del progetto",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["project_id"],
|
||||||
|
"properties": {
|
||||||
|
"project_id": {"type": "string"},
|
||||||
|
"session_limit": {"type": "integer", "default": 5},
|
||||||
|
"include_gitea": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": True,
|
||||||
|
"description": "Include README/docs dal repo Gitea collegato",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "save_context",
|
||||||
|
"description": "Salva o appende memoria persistente in un progetto",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["project_id", "content"],
|
||||||
|
"properties": {
|
||||||
|
"project_id": {"type": "string"},
|
||||||
|
"content": {"type": "string"},
|
||||||
|
"mode": {"type": "string", "enum": ["append", "replace"], "default": "append"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "archive_project",
|
||||||
|
"description": "Archivia o ripristina un progetto",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["project_id"],
|
||||||
|
"properties": {
|
||||||
|
"project_id": {"type": "string"},
|
||||||
|
"archived": {"type": "boolean", "default": True},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search_context",
|
||||||
|
"description": "Ricerca semantica nei contesti salvati dell'utente",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["query"],
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string"},
|
||||||
|
"limit": {"type": "integer", "default": 8},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search_knowledge",
|
||||||
|
"description": "Ricerca semantica su Paperless, contesti salvati e repository Gitea indicizzati",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["query"],
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string"},
|
||||||
|
"limit": {"type": "integer", "default": 8},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search_gitea_knowledge",
|
||||||
|
"description": "Ricerca semantica solo sui file Gitea indicizzati (runbook, markdown, codice)",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["query"],
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string"},
|
||||||
|
"limit": {"type": "integer", "default": 8},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_gitea_indexed_files",
|
||||||
|
"description": "Elenca gli ultimi file Gitea indicizzati nel vector store",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"repo": {"type": "string", "description": "Filtra per owner/name"},
|
||||||
|
"limit": {"type": "integer", "default": 30},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "reindex_gitea_repo",
|
||||||
|
"description": "Re-indicizza i file testo di un repository Gitea (admin o owner repo)",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["repo"],
|
||||||
|
"properties": {
|
||||||
|
"repo": {"type": "string", "description": "owner/name, es. daniele/rete"},
|
||||||
|
"max_files": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Limite file per questa esecuzione (default: config globale)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_document",
|
||||||
|
"description": "Recupera il contenuto testuale di un documento Paperless per ID",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["doc_id"],
|
||||||
|
"properties": {"doc_id": {"type": "integer"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_recent_documents",
|
||||||
|
"description": "Elenca gli ultimi documenti indicizzati",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"limit": {"type": "integer", "default": 20}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "reindex_document",
|
||||||
|
"description": "Re-indicizza un documento Paperless (admin)",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["doc_id"],
|
||||||
|
"properties": {"doc_id": {"type": "integer"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_repos",
|
||||||
|
"description": "Elenca i repository Gitea accessibili all'utente corrente",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"page": {"type": "integer", "default": 1},
|
||||||
|
"limit": {"type": "integer", "default": 50},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_file",
|
||||||
|
"description": "Legge un file (o elenca una directory) da un repository Gitea",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["repo", "path"],
|
||||||
|
"properties": {
|
||||||
|
"repo": {"type": "string", "description": "owner/name, es. daniele/rete"},
|
||||||
|
"path": {"type": "string", "description": "Percorso nel repo, es. ha/RUNBOOK-failover.md"},
|
||||||
|
"ref": {"type": "string", "description": "Branch o tag (default: branch predefinito del repo)"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search_code",
|
||||||
|
"description": "Cerca testo nel codice o nei file di un repository Gitea",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["query"],
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string"},
|
||||||
|
"repo": {"type": "string", "description": "Limita la ricerca a owner/name"},
|
||||||
|
"limit": {"type": "integer", "default": 20},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_issues",
|
||||||
|
"description": "Elenca le issue di un repository Gitea",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["repo"],
|
||||||
|
"properties": {
|
||||||
|
"repo": {"type": "string"},
|
||||||
|
"state": {"type": "string", "enum": ["open", "closed", "all"], "default": "open"},
|
||||||
|
"page": {"type": "integer", "default": 1},
|
||||||
|
"limit": {"type": "integer", "default": 20},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_issue",
|
||||||
|
"description": "Recupera una issue Gitea per numero",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["repo", "number"],
|
||||||
|
"properties": {
|
||||||
|
"repo": {"type": "string"},
|
||||||
|
"number": {"type": "integer"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_issue",
|
||||||
|
"description": "Crea una nuova issue su un repository Gitea",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["repo", "title"],
|
||||||
|
"properties": {
|
||||||
|
"repo": {"type": "string"},
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"body": {"type": "string", "default": ""},
|
||||||
|
"labels": {"type": "array", "items": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_gitea_repo",
|
||||||
|
"description": "Crea un nuovo repository Gitea sotto l'utente corrente (workspace personale)",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name"],
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "Nome repo (es. progetti)"},
|
||||||
|
"private": {"type": "boolean", "default": True},
|
||||||
|
"description": {"type": "string", "default": ""},
|
||||||
|
"auto_init": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": True,
|
||||||
|
"description": "Crea README.md iniziale",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_or_update_file",
|
||||||
|
"description": "Crea o aggiorna un file su Gitea (commit singolo via API, equivalente a push di un file)",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["repo", "path", "content", "message"],
|
||||||
|
"properties": {
|
||||||
|
"repo": {"type": "string", "description": "owner/name"},
|
||||||
|
"path": {"type": "string", "description": "Percorso file nel repo"},
|
||||||
|
"content": {"type": "string", "description": "Contenuto testo del file"},
|
||||||
|
"message": {"type": "string", "description": "Messaggio di commit"},
|
||||||
|
"branch": {"type": "string", "description": "Branch (default: branch principale del repo)"},
|
||||||
|
"reindex": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": True,
|
||||||
|
"description": "Aggiorna subito il vector store RAG per questo file",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_home_dashboard",
|
||||||
|
"description": "Dashboard Loogle Casa: meteo, alert, rete, notifiche",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_home_weather",
|
||||||
|
"description": "Meteo attuale e previsioni per casa (Loogle Casa)",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_network_overview",
|
||||||
|
"description": "Panoramica rete domestica: dispositivi online/offline, IP pubblico",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_network_failover_status",
|
||||||
|
"description": "Stato cluster failover LOOGLE (Pi, NAS, servizi)",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_ha_entity",
|
||||||
|
"description": "Legge lo stato di un'entità Home Assistant (read-only)",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["entity_id"],
|
||||||
|
"properties": {
|
||||||
|
"entity_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Es. switch.pompa_pozzo, sensor.temperatura_sala",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_ha_entities",
|
||||||
|
"description": "Elenca entità Home Assistant, opzionalmente filtrate per dominio",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"domain": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filtra per dominio: switch, sensor, light, climate, …",
|
||||||
|
},
|
||||||
|
"limit": {"type": "integer", "default": 50},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search_ha_entities",
|
||||||
|
"description": "Cerca entità Home Assistant per nome o entity_id",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["query"],
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string"},
|
||||||
|
"limit": {"type": "integer", "default": 20},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_irrigation_status",
|
||||||
|
"description": "Stato irrigazione: zone, programma, pozzo, sensori, connessione HA",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_irrigation_zones",
|
||||||
|
"description": "Elenco zone irrigazione con stato valvole e portata",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_irrigation_history",
|
||||||
|
"description": "Storico irrigazioni recenti",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"limit": {"type": "integer", "default": 30}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_turni_status",
|
||||||
|
"description": "Stato servizio Turni-Live (versione, ambiente)",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_my_shifts",
|
||||||
|
"description": "Turni di lavoro dell'utente corrente (Turni-Live)",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"from_date": {"type": "string", "description": "ISO date YYYY-MM-DD"},
|
||||||
|
"to_date": {"type": "string", "description": "ISO date YYYY-MM-DD"},
|
||||||
|
"limit": {"type": "integer", "default": 50},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_turni_doctors",
|
||||||
|
"description": "Elenco medici/operatori in Turni-Live",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search_apps_knowledge",
|
||||||
|
"description": "Ricerca semantica su storico Irrigazione e Turni indicizzati",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["query"],
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string"},
|
||||||
|
"source": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["irrigazione", "turni"],
|
||||||
|
"description": "Filtra per sorgente app",
|
||||||
|
},
|
||||||
|
"limit": {"type": "integer", "default": 8},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_apps_indexed",
|
||||||
|
"description": "Elenca record Irrigazione/Turni indicizzati nel vector store",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"source": {"type": "string", "enum": ["irrigazione", "turni"]},
|
||||||
|
"limit": {"type": "integer", "default": 30},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "reindex_apps",
|
||||||
|
"description": "Forza re-indicizzazione RAG da Irrigazione e/o Turni (admin)",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"source": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["irrigazione", "turni", "all"],
|
||||||
|
"default": "all",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _text_result(payload: Any) -> dict:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)}]}
|
||||||
|
|
||||||
|
|
||||||
|
def call_tool(name: str, arguments: dict, claims: dict) -> dict:
|
||||||
|
username = claims["sub"]
|
||||||
|
is_admin = "admin" in (claims.get("scope") or "").split()
|
||||||
|
|
||||||
|
if name == "ping":
|
||||||
|
audit.log_tool(username, name)
|
||||||
|
return _text_result({"pong": True, "service": "loogle-mcp"})
|
||||||
|
|
||||||
|
if name == "whoami":
|
||||||
|
audit.log_tool(username, name)
|
||||||
|
return _text_result({"username": username, "scope": claims.get("scope"), "is_admin": is_admin})
|
||||||
|
|
||||||
|
if name == "list_projects":
|
||||||
|
if not has_scope(claims, "context:read"):
|
||||||
|
raise PermissionError("Scope context:read richiesto")
|
||||||
|
projects = context_store.list_projects(username, arguments.get("include_archived", False))
|
||||||
|
audit.log_tool(username, name)
|
||||||
|
return _text_result({"projects": projects})
|
||||||
|
|
||||||
|
if name == "create_project":
|
||||||
|
if not has_scope(claims, "context:write"):
|
||||||
|
raise PermissionError("Scope context:write richiesto")
|
||||||
|
gitea_repo = arguments.get("gitea_repo")
|
||||||
|
if gitea_repo and not has_scope(claims, "gitea:read"):
|
||||||
|
raise PermissionError("Scope gitea:read richiesto per collegare un repository")
|
||||||
|
try:
|
||||||
|
meta = context_store.create_project(
|
||||||
|
username,
|
||||||
|
arguments["title"],
|
||||||
|
arguments.get("tags"),
|
||||||
|
gitea_repo=gitea_repo,
|
||||||
|
seed_from_gitea=bool(arguments.get("seed_from_gitea")),
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise PermissionError(str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(str(exc)) from exc
|
||||||
|
audit.log_tool(username, name, meta["id"])
|
||||||
|
return _text_result(meta)
|
||||||
|
|
||||||
|
if name == "link_project_repo":
|
||||||
|
if not has_scope(claims, "context:write"):
|
||||||
|
raise PermissionError("Scope context:write richiesto")
|
||||||
|
gitea_repo = arguments.get("gitea_repo")
|
||||||
|
if gitea_repo and not has_scope(claims, "gitea:read"):
|
||||||
|
raise PermissionError("Scope gitea:read richiesto per collegare un repository")
|
||||||
|
try:
|
||||||
|
meta = context_store.link_project_repo(
|
||||||
|
username,
|
||||||
|
arguments["project_id"],
|
||||||
|
gitea_repo=gitea_repo,
|
||||||
|
seed_from_gitea=bool(arguments.get("seed_from_gitea")),
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise PermissionError(str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(str(exc)) from exc
|
||||||
|
audit.log_tool(username, name, arguments["project_id"])
|
||||||
|
return _text_result(meta)
|
||||||
|
|
||||||
|
if name == "get_project_context":
|
||||||
|
if not has_scope(claims, "context:read"):
|
||||||
|
raise PermissionError("Scope context:read richiesto")
|
||||||
|
include_gitea = arguments.get("include_gitea", True)
|
||||||
|
if include_gitea and not has_scope(claims, "gitea:read"):
|
||||||
|
include_gitea = False
|
||||||
|
data = context_store.get_project_context(
|
||||||
|
username,
|
||||||
|
arguments["project_id"],
|
||||||
|
arguments.get("session_limit", 5),
|
||||||
|
include_gitea=include_gitea,
|
||||||
|
)
|
||||||
|
if include_gitea is False and arguments.get("include_gitea", True):
|
||||||
|
meta = data.get("meta") or {}
|
||||||
|
if meta.get("gitea_repo"):
|
||||||
|
data["gitea"] = {
|
||||||
|
"linked": True,
|
||||||
|
"repo": meta["gitea_repo"],
|
||||||
|
"available": False,
|
||||||
|
"error": "Scope gitea:read richiesto per arricchimento repository",
|
||||||
|
}
|
||||||
|
audit.log_tool(username, name, arguments["project_id"])
|
||||||
|
return _text_result(data)
|
||||||
|
|
||||||
|
if name == "save_context":
|
||||||
|
if not has_scope(claims, "context:write"):
|
||||||
|
raise PermissionError("Scope context:write richiesto")
|
||||||
|
meta = context_store.save_context(
|
||||||
|
username,
|
||||||
|
arguments["project_id"],
|
||||||
|
arguments["content"],
|
||||||
|
arguments.get("mode", "append"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
indexer.index_context_snippet(
|
||||||
|
username, arguments["project_id"], arguments["content"], meta["title"]
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
audit.log_tool(username, name, arguments["project_id"])
|
||||||
|
return _text_result(meta)
|
||||||
|
|
||||||
|
if name == "archive_project":
|
||||||
|
if not has_scope(claims, "context:write"):
|
||||||
|
raise PermissionError("Scope context:write richiesto")
|
||||||
|
meta = context_store.archive_project(
|
||||||
|
username, arguments["project_id"], arguments.get("archived", True)
|
||||||
|
)
|
||||||
|
audit.log_tool(username, name, arguments["project_id"])
|
||||||
|
return _text_result(meta)
|
||||||
|
|
||||||
|
if name == "search_context":
|
||||||
|
if not has_scope(claims, "context:read"):
|
||||||
|
raise PermissionError("Scope context:read richiesto")
|
||||||
|
hits = indexer.search_context(username, arguments["query"], arguments.get("limit", 8))
|
||||||
|
audit.log_tool(username, name, detail={"query": arguments["query"]})
|
||||||
|
return _text_result({"results": hits})
|
||||||
|
|
||||||
|
if name == "search_knowledge":
|
||||||
|
if not has_scope(claims, "knowledge:read"):
|
||||||
|
raise PermissionError("Scope knowledge:read richiesto")
|
||||||
|
hits = indexer.search_knowledge(
|
||||||
|
username, arguments["query"], arguments.get("limit", 8), is_admin=is_admin
|
||||||
|
)
|
||||||
|
audit.log_tool(username, name, detail={"query": arguments["query"]})
|
||||||
|
return _text_result({"results": hits})
|
||||||
|
|
||||||
|
if name == "search_gitea_knowledge":
|
||||||
|
if not has_scope(claims, "knowledge:read"):
|
||||||
|
raise PermissionError("Scope knowledge:read richiesto")
|
||||||
|
if not gitea.is_configured(username):
|
||||||
|
raise PermissionError("Gitea non configurato per questo utente")
|
||||||
|
hits = indexer.search_gitea_knowledge(
|
||||||
|
username, arguments["query"], arguments.get("limit", 8), is_admin=is_admin
|
||||||
|
)
|
||||||
|
audit.log_tool(username, name, detail={"query": arguments["query"]})
|
||||||
|
return _text_result({"results": hits})
|
||||||
|
|
||||||
|
if name == "list_gitea_indexed_files":
|
||||||
|
if not has_scope(claims, "knowledge:read"):
|
||||||
|
raise PermissionError("Scope knowledge:read richiesto")
|
||||||
|
files = gitea_indexer.list_indexed_files(
|
||||||
|
arguments.get("limit", 30),
|
||||||
|
repo=arguments.get("repo"),
|
||||||
|
)
|
||||||
|
audit.log_tool(username, name, detail={"repo": arguments.get("repo")})
|
||||||
|
return _text_result({"files": files, "count": len(files)})
|
||||||
|
|
||||||
|
if name == "reindex_gitea_repo":
|
||||||
|
if not has_scope(claims, "gitea:read"):
|
||||||
|
raise PermissionError("Scope gitea:read richiesto")
|
||||||
|
if not gitea.is_configured(username):
|
||||||
|
raise PermissionError("Gitea non configurato per questo utente")
|
||||||
|
repo = arguments["repo"]
|
||||||
|
owner = repo.split("/", 1)[0].lower()
|
||||||
|
if not is_admin and owner != username:
|
||||||
|
raise PermissionError("Puoi re-indicizzare solo repository di cui sei owner")
|
||||||
|
try:
|
||||||
|
result = gitea_indexer.index_repo(
|
||||||
|
repo,
|
||||||
|
username=username,
|
||||||
|
force=True,
|
||||||
|
max_files=arguments.get("max_files"),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermissionError(f"Re-indicizzazione Gitea fallita: {exc}") from exc
|
||||||
|
audit.log_tool(username, name, detail={"repo": repo})
|
||||||
|
return _text_result(result)
|
||||||
|
|
||||||
|
if name == "get_document":
|
||||||
|
if not has_scope(claims, "knowledge:read"):
|
||||||
|
raise PermissionError("Scope knowledge:read richiesto")
|
||||||
|
try:
|
||||||
|
text = paperless.download_document_text(int(arguments["doc_id"]), username=username)
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermissionError(f"Documento non accessibile con il tuo account Paperless: {exc}")
|
||||||
|
audit.log_tool(username, name, str(arguments["doc_id"]))
|
||||||
|
return _text_result({"doc_id": arguments["doc_id"], "content": text})
|
||||||
|
|
||||||
|
if name == "list_recent_documents":
|
||||||
|
if not has_scope(claims, "knowledge:read"):
|
||||||
|
raise PermissionError("Scope knowledge:read richiesto")
|
||||||
|
docs = indexer.list_recent_documents(arguments.get("limit", 20))
|
||||||
|
audit.log_tool(username, name)
|
||||||
|
return _text_result({"documents": docs})
|
||||||
|
|
||||||
|
if name == "reindex_document":
|
||||||
|
if not is_admin:
|
||||||
|
raise PermissionError("Scope admin richiesto")
|
||||||
|
result = indexer.index_document(int(arguments["doc_id"]), force=True)
|
||||||
|
audit.log_tool(username, name, str(arguments["doc_id"]))
|
||||||
|
return _text_result(result)
|
||||||
|
|
||||||
|
if name in (
|
||||||
|
"list_repos", "get_file", "search_code", "list_issues", "get_issue", "create_issue",
|
||||||
|
"create_gitea_repo", "create_or_update_file",
|
||||||
|
):
|
||||||
|
if not gitea.is_configured(username):
|
||||||
|
raise PermissionError(
|
||||||
|
"Gitea non configurato per questo utente. "
|
||||||
|
"Aggiungi GITEA_API_TOKEN_{USER} in .env — vedi docs/GITEA-TOKEN.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
if name == "list_repos":
|
||||||
|
if not has_scope(claims, "gitea:read"):
|
||||||
|
raise PermissionError("Scope gitea:read richiesto")
|
||||||
|
try:
|
||||||
|
result = gitea.list_repos(
|
||||||
|
username=username,
|
||||||
|
page=int(arguments.get("page", 1)),
|
||||||
|
limit=int(arguments.get("limit", 50)),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermissionError(f"Gitea non accessibile: {exc}") from exc
|
||||||
|
audit.log_tool(username, name)
|
||||||
|
return _text_result(result)
|
||||||
|
|
||||||
|
if name == "get_file":
|
||||||
|
if not has_scope(claims, "gitea:read"):
|
||||||
|
raise PermissionError("Scope gitea:read richiesto")
|
||||||
|
try:
|
||||||
|
result = gitea.get_file(
|
||||||
|
arguments["repo"],
|
||||||
|
arguments["path"],
|
||||||
|
ref=arguments.get("ref"),
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise ValueError(str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermissionError(f"File Gitea non accessibile: {exc}") from exc
|
||||||
|
audit.log_tool(username, name, detail={"repo": arguments["repo"], "path": arguments["path"]})
|
||||||
|
return _text_result(result)
|
||||||
|
|
||||||
|
if name == "search_code":
|
||||||
|
if not has_scope(claims, "gitea:read"):
|
||||||
|
raise PermissionError("Scope gitea:read richiesto")
|
||||||
|
try:
|
||||||
|
result = gitea.search_code(
|
||||||
|
arguments["query"],
|
||||||
|
repo=arguments.get("repo"),
|
||||||
|
limit=int(arguments.get("limit", 20)),
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermissionError(f"Ricerca Gitea fallita: {exc}") from exc
|
||||||
|
audit.log_tool(username, name, detail={"query": arguments["query"], "repo": arguments.get("repo")})
|
||||||
|
return _text_result(result)
|
||||||
|
|
||||||
|
if name == "list_issues":
|
||||||
|
if not has_scope(claims, "gitea:read"):
|
||||||
|
raise PermissionError("Scope gitea:read richiesto")
|
||||||
|
try:
|
||||||
|
result = gitea.list_issues(
|
||||||
|
arguments["repo"],
|
||||||
|
state=arguments.get("state", "open"),
|
||||||
|
page=int(arguments.get("page", 1)),
|
||||||
|
limit=int(arguments.get("limit", 20)),
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermissionError(f"Issue Gitea non accessibili: {exc}") from exc
|
||||||
|
audit.log_tool(username, name, detail={"repo": arguments["repo"]})
|
||||||
|
return _text_result(result)
|
||||||
|
|
||||||
|
if name == "get_issue":
|
||||||
|
if not has_scope(claims, "gitea:read"):
|
||||||
|
raise PermissionError("Scope gitea:read richiesto")
|
||||||
|
try:
|
||||||
|
result = gitea.get_issue(
|
||||||
|
arguments["repo"],
|
||||||
|
int(arguments["number"]),
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermissionError(f"Issue Gitea non accessibile: {exc}") from exc
|
||||||
|
audit.log_tool(username, name, detail={"repo": arguments["repo"], "number": arguments["number"]})
|
||||||
|
return _text_result(result)
|
||||||
|
|
||||||
|
if name == "create_issue":
|
||||||
|
if not has_scope(claims, "gitea:write"):
|
||||||
|
raise PermissionError("Scope gitea:write richiesto")
|
||||||
|
try:
|
||||||
|
result = gitea.create_issue(
|
||||||
|
arguments["repo"],
|
||||||
|
arguments["title"],
|
||||||
|
body=arguments.get("body", ""),
|
||||||
|
labels=arguments.get("labels"),
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermissionError(f"Creazione issue Gitea fallita: {exc}") from exc
|
||||||
|
audit.log_tool(username, name, detail={"repo": arguments["repo"], "title": arguments["title"]})
|
||||||
|
return _text_result(result)
|
||||||
|
|
||||||
|
if name == "create_gitea_repo":
|
||||||
|
if not has_scope(claims, "gitea:write"):
|
||||||
|
raise PermissionError("Scope gitea:write richiesto")
|
||||||
|
try:
|
||||||
|
result = gitea.create_repo(
|
||||||
|
arguments["name"],
|
||||||
|
username=username,
|
||||||
|
private=bool(arguments.get("private", True)),
|
||||||
|
description=arguments.get("description", ""),
|
||||||
|
auto_init=bool(arguments.get("auto_init", True)),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermissionError(f"Creazione repo Gitea fallita: {exc}") from exc
|
||||||
|
audit.log_tool(username, name, detail={"name": arguments["name"]})
|
||||||
|
return _text_result(result)
|
||||||
|
|
||||||
|
if name == "create_or_update_file":
|
||||||
|
if not has_scope(claims, "gitea:write"):
|
||||||
|
raise PermissionError("Scope gitea:write richiesto")
|
||||||
|
repo = arguments["repo"]
|
||||||
|
try:
|
||||||
|
gitea.assert_repo_owner(username, repo, is_admin=is_admin)
|
||||||
|
result = gitea.create_or_update_file(
|
||||||
|
repo,
|
||||||
|
arguments["path"],
|
||||||
|
arguments["content"],
|
||||||
|
arguments["message"],
|
||||||
|
branch=arguments.get("branch"),
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
|
if arguments.get("reindex", True):
|
||||||
|
try:
|
||||||
|
owner, repo_name = gitea.parse_repo(repo)
|
||||||
|
meta = gitea._request(
|
||||||
|
"GET", f"/repos/{owner}/{repo_name}", username=username
|
||||||
|
)
|
||||||
|
private = bool(meta.get("private"))
|
||||||
|
idx = gitea_indexer.index_file(
|
||||||
|
repo,
|
||||||
|
arguments["path"],
|
||||||
|
username=username,
|
||||||
|
private=private,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
result["reindex"] = idx
|
||||||
|
except Exception:
|
||||||
|
result["reindex"] = {"skipped": True}
|
||||||
|
except PermissionError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermissionError(f"Scrittura file Gitea fallita: {exc}") from exc
|
||||||
|
audit.log_tool(
|
||||||
|
username,
|
||||||
|
name,
|
||||||
|
detail={"repo": repo, "path": arguments["path"], "action": result.get("action")},
|
||||||
|
)
|
||||||
|
return _text_result(result)
|
||||||
|
|
||||||
|
if name in (
|
||||||
|
"get_home_dashboard", "get_home_weather", "get_network_overview",
|
||||||
|
"get_network_failover_status",
|
||||||
|
):
|
||||||
|
if not has_scope(claims, "home:read"):
|
||||||
|
raise PermissionError("Scope home:read richiesto")
|
||||||
|
if not casa.is_configured(username):
|
||||||
|
raise PermissionError(
|
||||||
|
"Loogle Casa non configurato. Imposta LOOGLE_CASA_PASSWORD_{USER} in .env"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if name == "get_home_dashboard":
|
||||||
|
payload = casa.get_dashboard(username)
|
||||||
|
elif name == "get_home_weather":
|
||||||
|
payload = casa.get_weather_home(username)
|
||||||
|
elif name == "get_network_overview":
|
||||||
|
payload = casa.get_network_overview(username)
|
||||||
|
else:
|
||||||
|
payload = casa.get_network_failover_status(username)
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermissionError(f"Loogle Casa non accessibile: {exc}") from exc
|
||||||
|
audit.log_tool(username, name)
|
||||||
|
return _text_result(payload)
|
||||||
|
|
||||||
|
if name in ("get_ha_entity", "list_ha_entities", "search_ha_entities"):
|
||||||
|
if not has_scope(claims, "home:read"):
|
||||||
|
raise PermissionError("Scope home:read richiesto")
|
||||||
|
if not homeassistant.is_configured():
|
||||||
|
raise PermissionError("Home Assistant non configurato — imposta HA_TOKEN in .env")
|
||||||
|
try:
|
||||||
|
if name == "get_ha_entity":
|
||||||
|
payload = homeassistant.get_entity(arguments["entity_id"])
|
||||||
|
elif name == "list_ha_entities":
|
||||||
|
payload = {
|
||||||
|
"entities": homeassistant.list_entities(
|
||||||
|
domain=arguments.get("domain"),
|
||||||
|
limit=int(arguments.get("limit", 50)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
payload = {
|
||||||
|
"results": homeassistant.search_entities(
|
||||||
|
arguments["query"],
|
||||||
|
limit=int(arguments.get("limit", 20)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermissionError(f"Home Assistant non accessibile: {exc}") from exc
|
||||||
|
audit.log_tool(username, name, detail=arguments.get("entity_id") or arguments.get("query"))
|
||||||
|
return _text_result(payload)
|
||||||
|
|
||||||
|
if name in ("get_irrigation_status", "get_irrigation_zones", "get_irrigation_history"):
|
||||||
|
if not has_scope(claims, "irrigation:read"):
|
||||||
|
raise PermissionError("Scope irrigation:read richiesto")
|
||||||
|
if not irrigazione.is_configured(username):
|
||||||
|
raise PermissionError(
|
||||||
|
"Irrigazione non configurata. Imposta IRRIGAZIONE_PASSWORD_{USER} in .env"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if name == "get_irrigation_status":
|
||||||
|
payload = irrigazione.get_status(username)
|
||||||
|
elif name == "get_irrigation_zones":
|
||||||
|
payload = irrigazione.get_zones(username)
|
||||||
|
else:
|
||||||
|
payload = irrigazione.get_history(username, int(arguments.get("limit", 30)))
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermissionError(f"Irrigazione non accessibile: {exc}") from exc
|
||||||
|
audit.log_tool(username, name)
|
||||||
|
return _text_result(payload)
|
||||||
|
|
||||||
|
if name in ("get_turni_status", "get_my_shifts", "list_turni_doctors"):
|
||||||
|
if name != "get_turni_status" and not has_scope(claims, "turni:read"):
|
||||||
|
raise PermissionError("Scope turni:read richiesto")
|
||||||
|
try:
|
||||||
|
if name == "get_turni_status":
|
||||||
|
payload = turni.get_status()
|
||||||
|
elif name == "list_turni_doctors":
|
||||||
|
if not turni.is_configured(username):
|
||||||
|
raise PermissionError("Turni non configurato per questo utente")
|
||||||
|
payload = {"doctors": turni.list_doctors(username)}
|
||||||
|
else:
|
||||||
|
if not turni.is_configured(username):
|
||||||
|
raise PermissionError("Turni non configurato per questo utente")
|
||||||
|
payload = turni.get_my_shifts(
|
||||||
|
username,
|
||||||
|
from_date=arguments.get("from_date"),
|
||||||
|
to_date=arguments.get("to_date"),
|
||||||
|
limit=int(arguments.get("limit", 50)),
|
||||||
|
)
|
||||||
|
except PermissionError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermissionError(f"Turni non accessibile: {exc}") from exc
|
||||||
|
audit.log_tool(username, name)
|
||||||
|
return _text_result(payload)
|
||||||
|
|
||||||
|
if name == "search_apps_knowledge":
|
||||||
|
if not has_scope(claims, "knowledge:read"):
|
||||||
|
raise PermissionError("Scope knowledge:read richiesto")
|
||||||
|
hits = apps_indexer.search_apps_knowledge(
|
||||||
|
arguments["query"],
|
||||||
|
limit=int(arguments.get("limit", 8)),
|
||||||
|
source=arguments.get("source"),
|
||||||
|
)
|
||||||
|
audit.log_tool(username, name, detail={"query": arguments["query"]})
|
||||||
|
return _text_result({"results": hits})
|
||||||
|
|
||||||
|
if name == "list_apps_indexed":
|
||||||
|
if not has_scope(claims, "knowledge:read"):
|
||||||
|
raise PermissionError("Scope knowledge:read richiesto")
|
||||||
|
records = apps_indexer.list_indexed_records(
|
||||||
|
source=arguments.get("source"),
|
||||||
|
limit=int(arguments.get("limit", 30)),
|
||||||
|
)
|
||||||
|
audit.log_tool(username, name)
|
||||||
|
return _text_result({"records": records, "count": len(records)})
|
||||||
|
|
||||||
|
if name == "reindex_apps":
|
||||||
|
if not is_admin:
|
||||||
|
raise PermissionError("Scope admin richiesto")
|
||||||
|
src = arguments.get("source", "all")
|
||||||
|
if src == "irrigazione":
|
||||||
|
result = apps_indexer.index_irrigazione()
|
||||||
|
elif src == "turni":
|
||||||
|
result = apps_indexer.index_turni()
|
||||||
|
else:
|
||||||
|
result = apps_indexer.index_all()
|
||||||
|
audit.log_tool(username, name, detail={"source": src})
|
||||||
|
return _text_result(result)
|
||||||
|
|
||||||
|
raise ValueError(f"Tool sconosciuto: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def list_resources(claims: dict) -> list:
|
||||||
|
username = claims["sub"]
|
||||||
|
return context_store.list_resources(username)
|
||||||
|
|
||||||
|
|
||||||
|
def read_resource(uri: str, claims: dict) -> dict:
|
||||||
|
username = claims["sub"]
|
||||||
|
return context_store.read_resource(username, uri)
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""OAuth 2.1 Authorization Code + PKCE."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from . import auth
|
||||||
|
from .db import get_conn
|
||||||
|
from .jwt_utils import (
|
||||||
|
ACCESS_TOKEN_HOURS,
|
||||||
|
base_url,
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
decode_access_token,
|
||||||
|
revoke_refresh_token,
|
||||||
|
scopes_for_user,
|
||||||
|
consume_refresh_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pkce_valid(code_verifier: str, challenge: str, method: str) -> bool:
|
||||||
|
if method != "S256":
|
||||||
|
return False
|
||||||
|
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||||
|
computed = base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
||||||
|
return computed == challenge
|
||||||
|
|
||||||
|
|
||||||
|
TRUSTED_REDIRECT_URIS = frozenset({
|
||||||
|
"https://claude.ai/api/mcp/auth_callback",
|
||||||
|
"https://chatgpt.com/connector_platform_oauth_redirect",
|
||||||
|
"https://chat.openai.com/connector_platform_oauth_redirect",
|
||||||
|
# Cursor IDE / Agents (docs.cursor.com/mcp)
|
||||||
|
"https://www.cursor.com/agents/mcp/oauth/callback",
|
||||||
|
"http://localhost:8787/callback",
|
||||||
|
# Legacy Cursor desktop
|
||||||
|
"cursor://anysphere.cursor-mcp/oauth/callback",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_client() -> None:
|
||||||
|
clients = [
|
||||||
|
(
|
||||||
|
os.environ.get("MCP_OAUTH_CLIENT_ID", "loogle-mcp-public"),
|
||||||
|
"Loogle MCP Public",
|
||||||
|
[
|
||||||
|
"https://chatgpt.com/connector_platform_oauth_redirect",
|
||||||
|
"https://chat.openai.com/connector_platform_oauth_redirect",
|
||||||
|
"https://claude.ai/api/mcp/auth_callback",
|
||||||
|
"https://www.cursor.com/agents/mcp/oauth/callback",
|
||||||
|
"http://localhost:8787/callback",
|
||||||
|
"cursor://anysphere.cursor-mcp/oauth/callback",
|
||||||
|
"http://127.0.0.1:*/callback",
|
||||||
|
"http://localhost:*/callback",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"cursor",
|
||||||
|
"Cursor IDE",
|
||||||
|
[
|
||||||
|
"https://www.cursor.com/agents/mcp/oauth/callback",
|
||||||
|
"http://localhost:8787/callback",
|
||||||
|
"cursor://anysphere.cursor-mcp/oauth/callback",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"claude-desktop",
|
||||||
|
"Claude Desktop/App",
|
||||||
|
["https://claude.ai/api/mcp/auth_callback"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"claude-ai",
|
||||||
|
"Claude.ai",
|
||||||
|
["https://claude.ai/api/mcp/auth_callback"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
conn = get_conn()
|
||||||
|
for client_id, client_name, redirect_uris in clients:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT client_id FROM oauth_clients WHERE client_id=?", (client_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE oauth_clients SET client_name=?, redirect_uris=? WHERE client_id=?",
|
||||||
|
(client_name, json.dumps(redirect_uris), client_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)",
|
||||||
|
(client_id, client_name, json.dumps(redirect_uris)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_trusted_redirect_uri(redirect_uri: str) -> bool:
|
||||||
|
if redirect_uri in TRUSTED_REDIRECT_URIS:
|
||||||
|
return True
|
||||||
|
parsed = urlparse(redirect_uri)
|
||||||
|
if parsed.scheme == "http" and parsed.hostname in ("127.0.0.1", "localhost"):
|
||||||
|
if (parsed.path or "").endswith("/callback"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def register_client(client_name: str, redirect_uris: list[str]) -> dict:
|
||||||
|
client_id = secrets.token_urlsafe(16)
|
||||||
|
for uri in redirect_uris:
|
||||||
|
if not _is_trusted_redirect_uri(uri):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Redirect URI non consentito: {uri}",
|
||||||
|
)
|
||||||
|
get_conn().execute(
|
||||||
|
"INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)",
|
||||||
|
(client_id, client_name, json.dumps(redirect_uris)),
|
||||||
|
)
|
||||||
|
get_conn().commit()
|
||||||
|
return {"client_id": client_id, "client_name": client_name, "redirect_uris": redirect_uris}
|
||||||
|
|
||||||
|
|
||||||
|
def _client_redirect_uris(client_id: str) -> list[str]:
|
||||||
|
row = get_conn().execute(
|
||||||
|
"SELECT redirect_uris FROM oauth_clients WHERE client_id=?", (client_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return []
|
||||||
|
return json.loads(row["redirect_uris"])
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_client_for_redirect(client_id: str, redirect_uri: str) -> None:
|
||||||
|
"""Registra client OAuth al volo (Claude usa spesso client_id = username)."""
|
||||||
|
conn = get_conn()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT redirect_uris FROM oauth_clients WHERE client_id=?", (client_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
uris = set(json.loads(row["redirect_uris"]))
|
||||||
|
if redirect_uri not in uris:
|
||||||
|
uris.add(redirect_uri)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE oauth_clients SET redirect_uris=? WHERE client_id=?",
|
||||||
|
(json.dumps(sorted(uris)), client_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)",
|
||||||
|
(client_id, f"MCP client {client_id}", json.dumps([redirect_uri])),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect_allowed(client_id: str, redirect_uri: str) -> bool:
|
||||||
|
if _is_trusted_redirect_uri(redirect_uri):
|
||||||
|
_ensure_client_for_redirect(client_id, redirect_uri)
|
||||||
|
return True
|
||||||
|
allowed = _client_redirect_uris(client_id)
|
||||||
|
if redirect_uri in allowed:
|
||||||
|
return True
|
||||||
|
parsed = urlparse(redirect_uri)
|
||||||
|
for pattern in allowed:
|
||||||
|
if "*" in pattern:
|
||||||
|
pp = urlparse(pattern.replace("*", "placeholder"))
|
||||||
|
if parsed.scheme == pp.scheme and parsed.netloc.endswith(pp.netloc.split("placeholder")[-1]):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_auth_code(
|
||||||
|
client_id: str,
|
||||||
|
user_id: int,
|
||||||
|
redirect_uri: str,
|
||||||
|
scope: str,
|
||||||
|
code_challenge: Optional[str],
|
||||||
|
code_challenge_method: Optional[str],
|
||||||
|
) -> str:
|
||||||
|
code = secrets.token_urlsafe(32)
|
||||||
|
expires = (
|
||||||
|
datetime.datetime.utcnow() + datetime.timedelta(minutes=10)
|
||||||
|
).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
get_conn().execute(
|
||||||
|
"INSERT INTO oauth_codes(code,client_id,user_id,redirect_uri,scope,code_challenge,code_challenge_method,expires_at)"
|
||||||
|
" VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
(code, client_id, user_id, redirect_uri, scope, code_challenge, code_challenge_method, expires),
|
||||||
|
)
|
||||||
|
get_conn().commit()
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def exchange_code(
|
||||||
|
code: str,
|
||||||
|
client_id: str,
|
||||||
|
redirect_uri: str,
|
||||||
|
code_verifier: Optional[str],
|
||||||
|
) -> dict:
|
||||||
|
row = get_conn().execute(
|
||||||
|
"SELECT * FROM oauth_codes WHERE code=? AND used=0 AND expires_at > datetime('now')",
|
||||||
|
(code,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(400, "Codice non valido o scaduto")
|
||||||
|
row = dict(row)
|
||||||
|
if row["client_id"] != client_id or row["redirect_uri"] != redirect_uri:
|
||||||
|
raise HTTPException(400, "Client o redirect URI non validi")
|
||||||
|
if row.get("code_challenge"):
|
||||||
|
if not code_verifier or not _pkce_valid(code_verifier, row["code_challenge"], row.get("code_challenge_method") or "S256"):
|
||||||
|
raise HTTPException(400, "PKCE verification failed")
|
||||||
|
user = auth.get_user_by_id(row["user_id"])
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(400, "Utente non trovato")
|
||||||
|
get_conn().execute("UPDATE oauth_codes SET used=1 WHERE code=?", (code,))
|
||||||
|
get_conn().commit()
|
||||||
|
scope = scopes_for_user(user, row["scope"])
|
||||||
|
access_token, _ = create_access_token(user, scope, client_id)
|
||||||
|
refresh = create_refresh_token(user["id"], scope, client_id)
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": ACCESS_TOKEN_HOURS * 3600,
|
||||||
|
"refresh_token": refresh,
|
||||||
|
"scope": scope,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_access_token(refresh_token: str, client_id: str) -> dict:
|
||||||
|
row = consume_refresh_token(refresh_token)
|
||||||
|
if not row or row["client_id"] != client_id:
|
||||||
|
raise HTTPException(400, "Refresh token non valido")
|
||||||
|
user = auth.get_user_by_id(row["user_id"])
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(400, "Utente non trovato")
|
||||||
|
revoke_refresh_token(refresh_token)
|
||||||
|
scope = row["scope"]
|
||||||
|
access_token, _ = create_access_token(user, scope, client_id)
|
||||||
|
refresh = create_refresh_token(user["id"], scope, client_id)
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": ACCESS_TOKEN_HOURS * 3600,
|
||||||
|
"refresh_token": refresh,
|
||||||
|
"scope": scope,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def authorization_server_metadata() -> dict:
|
||||||
|
base = base_url()
|
||||||
|
return {
|
||||||
|
"issuer": base,
|
||||||
|
"authorization_endpoint": f"{base}/authorize",
|
||||||
|
"token_endpoint": f"{base}/token",
|
||||||
|
"registration_endpoint": f"{base}/register",
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||||
|
"code_challenge_methods_supported": ["S256"],
|
||||||
|
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
|
||||||
|
"scopes_supported": [
|
||||||
|
"context:read",
|
||||||
|
"context:write",
|
||||||
|
"knowledge:read",
|
||||||
|
"knowledge:write",
|
||||||
|
"gitea:read",
|
||||||
|
"gitea:write",
|
||||||
|
"home:read",
|
||||||
|
"irrigation:read",
|
||||||
|
"turni:read",
|
||||||
|
"admin",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def protected_resource_metadata() -> dict:
|
||||||
|
base = base_url()
|
||||||
|
return {
|
||||||
|
"resource": f"{base}/mcp",
|
||||||
|
"authorization_servers": [base],
|
||||||
|
"scopes_supported": [
|
||||||
|
"context:read",
|
||||||
|
"context:write",
|
||||||
|
"knowledge:read",
|
||||||
|
"knowledge:write",
|
||||||
|
"gitea:read",
|
||||||
|
"gitea:write",
|
||||||
|
"home:read",
|
||||||
|
"irrigation:read",
|
||||||
|
"turni:read",
|
||||||
|
],
|
||||||
|
"bearer_methods_supported": ["header"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_authorize_redirect(
|
||||||
|
client_id: str,
|
||||||
|
redirect_uri: str,
|
||||||
|
scope: str,
|
||||||
|
state: str,
|
||||||
|
code_challenge: Optional[str],
|
||||||
|
code_challenge_method: Optional[str],
|
||||||
|
user_id: int,
|
||||||
|
) -> str:
|
||||||
|
if not _redirect_allowed(client_id, redirect_uri):
|
||||||
|
raise HTTPException(400, "Redirect URI non autorizzato")
|
||||||
|
code = create_auth_code(
|
||||||
|
client_id, user_id, redirect_uri, scope, code_challenge, code_challenge_method
|
||||||
|
)
|
||||||
|
params = {"code": code, "state": state}
|
||||||
|
sep = "&" if "?" in redirect_uri else "?"
|
||||||
|
return f"{redirect_uri}{sep}{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
def bearer_claims_from_header(authorization: str) -> Optional[dict]:
|
||||||
|
if not authorization.lower().startswith("bearer "):
|
||||||
|
return None
|
||||||
|
token = authorization[7:].strip()
|
||||||
|
return decode_access_token(token)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Loogle MCP Dashboard</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#0f172a; --card:#1e293b; --text:#e2e8f0; --muted:#94a3b8; --accent:#2563eb; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 1rem; }
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: .25rem; }
|
||||||
|
.sub { color: var(--muted); margin-bottom: 1.5rem; }
|
||||||
|
.card { background: var(--card); border-radius: 12px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
||||||
|
button { background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: .55rem 1rem; cursor: pointer; }
|
||||||
|
button.secondary { background: #334155; }
|
||||||
|
input { width: 100%; padding: .5rem; border-radius: 6px; border: 1px solid #334155; background: var(--bg); color: var(--text); margin: .35rem 0 .75rem; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
||||||
|
th, td { text-align: left; padding: .45rem .25rem; border-bottom: 1px solid #334155; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
.error { color: #f87171; }
|
||||||
|
code { background: #0b1220; padding: .15rem .35rem; border-radius: 4px; font-size: .85rem; }
|
||||||
|
ul { padding-left: 1.2rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Loogle MCP Hub</h1>
|
||||||
|
<p class="sub">Archivio contesto e knowledge base locale per AI famiglia</p>
|
||||||
|
|
||||||
|
<div id="loginView" class="card">
|
||||||
|
<h2>Accedi</h2>
|
||||||
|
<label>Utente</label>
|
||||||
|
<input id="loginUser" autocomplete="username">
|
||||||
|
<label>Password</label>
|
||||||
|
<input id="loginPass" type="password" autocomplete="current-password">
|
||||||
|
<button onclick="doLogin()">Entra</button>
|
||||||
|
<p id="loginErr" class="error hidden"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="appView" class="hidden">
|
||||||
|
<div class="card">
|
||||||
|
<strong id="welcome"></strong>
|
||||||
|
<button class="secondary" onclick="doLogout()" style="float:right">Esci</button>
|
||||||
|
<p>MCP URL: <code>https://mcp.loogle.it/mcp</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="pwdCard">
|
||||||
|
<h3>Cambia password</h3>
|
||||||
|
<label>Password attuale</label><input id="oldPwd" type="password">
|
||||||
|
<label>Nuova password</label><input id="newPwd" type="password">
|
||||||
|
<button onclick="changePwd()">Salva</button>
|
||||||
|
<p id="pwdMsg"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card hidden" id="adminCard">
|
||||||
|
<h3>Admin — revoca refresh token</h3>
|
||||||
|
<label>Refresh token</label><input id="revokeToken">
|
||||||
|
<button onclick="revokeRefresh()">Revoca</button>
|
||||||
|
<p id="revokeMsg"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Progetti</h3>
|
||||||
|
<div id="projects"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Audit log recente</h3>
|
||||||
|
<table><thead><tr><th>Ora</th><th>Utente</th><th>Tool</th><th>Risorsa</th></tr></thead><tbody id="auditBody"></tbody></table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Collegamenti AI</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>ChatGPT:</strong> Impostazioni → Developer → Add MCP Connector → URL sopra</li>
|
||||||
|
<li><strong>Claude:</strong> Settings → Connectors → Add remote MCP server</li>
|
||||||
|
<li><strong>Gemini CLI:</strong> configura server remoto in settings MCP</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function api(path, opts={}) {
|
||||||
|
const r = await fetch(path, { credentials: 'same-origin', headers: {'Content-Type':'application/json', ...(opts.headers||{})}, ...opts });
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
function show(el, on) { document.getElementById(el).classList.toggle('hidden', !on); }
|
||||||
|
async function boot() {
|
||||||
|
try {
|
||||||
|
const me = await api('/api/me');
|
||||||
|
show('loginView', false); show('appView', true);
|
||||||
|
document.getElementById('welcome').textContent = 'Ciao ' + me.username + (me.is_admin ? ' (admin)' : '');
|
||||||
|
if (me.is_admin) document.getElementById('adminCard').classList.remove('hidden');
|
||||||
|
loadProjects(); loadAudit();
|
||||||
|
} catch { show('loginView', true); show('appView', false); }
|
||||||
|
}
|
||||||
|
async function doLogin() {
|
||||||
|
try {
|
||||||
|
await api('/api/login', { method:'POST', body: JSON.stringify({ username: loginUser.value, password: loginPass.value }) });
|
||||||
|
boot();
|
||||||
|
} catch (e) {
|
||||||
|
loginErr.textContent = 'Credenziali non valide'; loginErr.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function doLogout() { await api('/api/logout', { method:'POST', body:'{}' }); boot(); }
|
||||||
|
async function changePwd() {
|
||||||
|
try {
|
||||||
|
await api('/api/password', { method:'POST', body: JSON.stringify({ old_password: oldPwd.value, new_password: newPwd.value }) });
|
||||||
|
pwdMsg.textContent = 'Password aggiornata';
|
||||||
|
} catch (e) { pwdMsg.textContent = 'Errore: password attuale errata'; }
|
||||||
|
}
|
||||||
|
async function loadProjects() {
|
||||||
|
const rows = await api('/api/projects');
|
||||||
|
projects.innerHTML = rows.length ? rows.map(p => `<div><strong>${p.title}</strong> <small>${p.id}</small> — agg. ${p.updated_at||p.created_at}</div>`).join('') : '<em>Nessun progetto</em>';
|
||||||
|
}
|
||||||
|
async function loadAudit() {
|
||||||
|
const rows = await api('/api/audit?limit=30');
|
||||||
|
auditBody.innerHTML = rows.map(r => `<tr><td>${r.created_at}</td><td>${r.username}</td><td>${r.tool_name}</td><td>${r.resource_id||''}</td></tr>`).join('');
|
||||||
|
}
|
||||||
|
async function revokeRefresh() {
|
||||||
|
try {
|
||||||
|
await api('/api/admin/revoke-refresh', { method:'POST', body: JSON.stringify({ refresh_token: revokeToken.value }) });
|
||||||
|
revokeMsg.textContent = 'Token revocato';
|
||||||
|
} catch (e) { revokeMsg.textContent = 'Errore revoca'; }
|
||||||
|
}
|
||||||
|
boot();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
services:
|
||||||
|
loogle-mcp:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: loogle-mcp:latest
|
||||||
|
container_name: loogle-mcp
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8700:8700"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Rome
|
||||||
|
- MCP_DB=/data/loogle_mcp.db
|
||||||
|
- MCP_CONTEXT_ROOT=/data/context
|
||||||
|
- MCP_AUDIT_LOG=/data/audit.log
|
||||||
|
- MCP_VECTOR_FALLBACK=/data/vector_fallback.db
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
- /mnt/ha-apps/mcp/context:/data/context
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:8700/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
loogle-mcp-indexer:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: loogle-mcp:latest
|
||||||
|
container_name: loogle-mcp-indexer
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Rome
|
||||||
|
- MCP_DB=/data/loogle_mcp.db
|
||||||
|
- MCP_CONTEXT_ROOT=/data/context
|
||||||
|
- MCP_VECTOR_FALLBACK=/data/vector_fallback.db
|
||||||
|
- DS920_SSH_HOST=192.168.128.100
|
||||||
|
- DS920_SSH_USER=daniely
|
||||||
|
- DS920_SSH_KEY=/run/secrets/ds920_ssh_key
|
||||||
|
- DS920_THERMAL_URL=http://192.168.128.100:9191/thermal
|
||||||
|
command: ["python", "-m", "worker.indexer_main"]
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
- /mnt/ha-apps/mcp/context:/data/context
|
||||||
|
- /home/daniely/.ssh/id_ed25519:/run/secrets/ds920_ssh_key:ro
|
||||||
|
depends_on:
|
||||||
|
- loogle-mcp
|
||||||
|
|
||||||
|
# Qdrant locale: solo su x86/DS920 — su Pi5 ARM 16K page size non supportato
|
||||||
|
qdrant-local:
|
||||||
|
profiles: ["qdrant-local"]
|
||||||
|
image: qdrant/qdrant:v1.13.2
|
||||||
|
container_name: loogle_mcp_qdrant
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "6333:6333"
|
||||||
|
volumes:
|
||||||
|
- /mnt/ha-apps/mcp/qdrant:/qdrant/storage
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Integrazione app homelab — P5/P6/P7
|
||||||
|
|
||||||
|
Tool live e RAG su **Loogle Casa**, **Home Assistant**, **Irrigazione** e **Turni**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P5 — Casa + Home Assistant
|
||||||
|
|
||||||
|
| Tool | Scope | Sorgente |
|
||||||
|
|------|-------|----------|
|
||||||
|
| `get_home_dashboard` | home:read | Loogle Casa |
|
||||||
|
| `get_home_weather` | home:read | Loogle Casa |
|
||||||
|
| `get_network_overview` | home:read | Loogle Casa |
|
||||||
|
| `get_network_failover_status` | home:read | Loogle Casa |
|
||||||
|
| `get_ha_entity` | home:read | Home Assistant |
|
||||||
|
| `list_ha_entities` | home:read | Home Assistant |
|
||||||
|
| `search_ha_entities` | home:read | Home Assistant |
|
||||||
|
|
||||||
|
### Variabili `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LOOGLE_CASA_URL=https://casa.loogle.it
|
||||||
|
LOOGLE_CASA_API_URL=http://192.168.128.81:5602 # opzionale, LAN
|
||||||
|
LOOGLE_CASA_PASSWORD_DANIELE=... # daniele MCP → admin Casa
|
||||||
|
|
||||||
|
HA_URL=https://ha.loogle.it
|
||||||
|
HA_API_URL=http://192.168.128.81:8123
|
||||||
|
HA_TOKEN=<long-lived token HA>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mapping utenti MCP → app:**
|
||||||
|
- `daniele` → Casa `admin`, Irrigazione `admin`, Turni `daniely`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P6 — Irrigazione + Turni
|
||||||
|
|
||||||
|
| Tool | Scope | Sorgente |
|
||||||
|
|------|-------|----------|
|
||||||
|
| `get_irrigation_status` | irrigation:read | irri.loogle.it |
|
||||||
|
| `get_irrigation_zones` | irrigation:read | irri.loogle.it |
|
||||||
|
| `get_irrigation_history` | irrigation:read | irri.loogle.it |
|
||||||
|
| `get_turni_status` | — (pubblico) | turni.loogle.it |
|
||||||
|
| `get_my_shifts` | turni:read | turni.loogle.it |
|
||||||
|
| `list_turni_doctors` | turni:read | turni.loogle.it |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IRRIGAZIONE_URL=https://irri.loogle.it
|
||||||
|
IRRIGAZIONE_API_URL=http://192.168.128.81:5601
|
||||||
|
IRRIGAZIONE_PASSWORD_DANIELE=...
|
||||||
|
|
||||||
|
TURNI_URL=https://turni.loogle.it
|
||||||
|
TURNI_PASSWORD_DANIELE=... # utente Turni: daniely
|
||||||
|
# oppure TURNI_JWT_DANIELE=...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P7 — RAG storico app
|
||||||
|
|
||||||
|
Il worker `loogle-mcp-indexer` indicizza periodicamente:
|
||||||
|
- snapshot stato/zone Irrigazione
|
||||||
|
- storico irrigazioni ed eventi
|
||||||
|
- lavori manutenzione giardino
|
||||||
|
- turni e anagrafica medici Turni
|
||||||
|
|
||||||
|
| Collection Qdrant | Contenuto |
|
||||||
|
|-------------------|-----------|
|
||||||
|
| `apps_shared_family` | Irrigazione + Turni (famiglia) |
|
||||||
|
|
||||||
|
| Tool | Scope | Funzione |
|
||||||
|
|------|-------|----------|
|
||||||
|
| `search_apps_knowledge` | knowledge:read | RAG solo app |
|
||||||
|
| `search_knowledge` | knowledge:read | Include anche app |
|
||||||
|
| `list_apps_indexed` | knowledge:read | Stato indicizzazione |
|
||||||
|
| `reindex_apps` | admin | Forza sync |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APPS_INDEX_ENABLED=yes
|
||||||
|
APPS_INDEX_USER=daniele
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec loogle-mcp python3 /srv/scripts/test_p5_home.py
|
||||||
|
docker exec loogle-mcp python3 /srv/scripts/test_p6_apps.py
|
||||||
|
docker exec loogle-mcp python3 /srv/scripts/test_p7_apps_rag.py
|
||||||
|
```
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
# Loogle MCP Hub — Architettura e parametri
|
||||||
|
|
||||||
|
Documento di riferimento per la piattaforma MCP domestica LOOGLE.IT.
|
||||||
|
Ultimo aggiornamento: 2026-08-22
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scopo
|
||||||
|
|
||||||
|
**Loogle MCP Hub** espone agli assistenti AI (Claude, ChatGPT, Gemini, Cursor) due capacità principali:
|
||||||
|
|
||||||
|
1. **Archivio contesto** — memoria persistente per progetti e task, isolata per utente
|
||||||
|
2. **Knowledge base RAG** — ricerca semantica su documenti Paperless e sui contesti salvati
|
||||||
|
|
||||||
|
Tutti i dati restano in LAN/NAS; l’accesso esterno avviene solo via HTTPS + OAuth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architettura logica
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client AI (ChatGPT / Claude / Gemini / Cursor) │
|
||||||
|
└───────────────────────────┬─────────────────────────────────┘
|
||||||
|
│ HTTPS + OAuth Bearer
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ NPM VIP 192.168.128.85 → mcp.loogle.it:443 │
|
||||||
|
└───────────────────────────┬─────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Pi-1 :8700 loogle-mcp (FastAPI) │
|
||||||
|
│ ├── /mcp JSON-RPC MCP │
|
||||||
|
│ ├── /oauth/* OAuth 2.1 PKCE │
|
||||||
|
│ └── /dashboard UI famiglia │
|
||||||
|
│ Pi-1 :8700 loogle-mcp-indexer (worker Paperless→vector) │
|
||||||
|
└───────┬─────────────────────────────┬───────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────┐ ┌─────────────────────┐
|
||||||
|
│ SQLite │ │ Vector store │
|
||||||
|
│ /data/*.db │ │ Qdrant DS920 :6333 │
|
||||||
|
│ utenti OAuth │ │ o fallback SQLite │
|
||||||
|
└───────────────┘ └─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ NFS /mnt/ha-apps/mcp/context/{utente}/projects/... │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Paperless docs.loogle.it (sorgente documenti + OCR) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Componenti e percorsi
|
||||||
|
|
||||||
|
| Componente | Host | Porta | Percorso / container |
|
||||||
|
|------------|------|-------|----------------------|
|
||||||
|
| MCP Gateway | Pi-1 | 8700 | `loogle-mcp` |
|
||||||
|
| Indexer | Pi-1 | — | `loogle-mcp-indexer` |
|
||||||
|
| Qdrant (primario) | DS920 | 6333 | opzionale, compose extrema |
|
||||||
|
| Vector fallback | Pi-1 | — | `/data/vector_fallback.db` |
|
||||||
|
| Context NFS | NAS | — | `/mnt/ha-apps/mcp/context/` |
|
||||||
|
| DB SQLite | Pi-1 | — | `/home/daniely/docker/loogle-mcp/data/loogle_mcp.db` |
|
||||||
|
| Compose locale | Pi-1 | — | `/home/daniely/docker/loogle-mcp/` |
|
||||||
|
| Compose failover | Pi-1/Pi-2 | — | `/home/daniely/rete/compose/failover/loogle-mcp-compose.yml` |
|
||||||
|
| Runbook | — | — | `/home/daniely/rete/ha/RUNBOOK-mcp.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URL e endpoint
|
||||||
|
|
||||||
|
| Servizio | URL |
|
||||||
|
|----------|-----|
|
||||||
|
| MCP (client AI) | `https://mcp.loogle.it/mcp` |
|
||||||
|
| Dashboard web | `https://mcp.loogle.it/dashboard` |
|
||||||
|
| Health | `https://mcp.loogle.it/health` |
|
||||||
|
| OAuth authorize | `https://mcp.loogle.it/oauth/authorize` |
|
||||||
|
| OAuth token | `https://mcp.loogle.it/oauth/token` |
|
||||||
|
| Metadata OAuth | `https://mcp.loogle.it/.well-known/oauth-authorization-server` |
|
||||||
|
| Metadata risorsa MCP | `https://mcp.loogle.it/.well-known/oauth-protected-resource` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DNS
|
||||||
|
|
||||||
|
| Record | Tipo | Valore | Dove configurato |
|
||||||
|
|--------|------|--------|------------------|
|
||||||
|
| `mcp.loogle.it` | A | `192.168.128.85` (VIP NPM) | Pi-hole `/etc/pihole/pihole.toml` → hosts |
|
||||||
|
| Risoluzione LAN | — | Pi-hole → Unbound → split-horizon | Come `docs.loogle.it`, `pass.loogle.it` |
|
||||||
|
|
||||||
|
**Script setup:** `/home/daniely/rete/scripts/setup-mcp-dns-npm.sh`
|
||||||
|
|
||||||
|
**Verifica:**
|
||||||
|
```bash
|
||||||
|
dig +short mcp.loogle.it @127.0.0.1 # → 192.168.128.85
|
||||||
|
curl -sf https://mcp.loogle.it/health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NPM (reverse proxy)
|
||||||
|
|
||||||
|
| Parametro | Valore |
|
||||||
|
|-----------|--------|
|
||||||
|
| Proxy host ID | 30 |
|
||||||
|
| Dominio | `mcp.loogle.it` |
|
||||||
|
| Upstream normale | `192.168.128.80:8700` |
|
||||||
|
| Certificato TLS | `npm-65` (Let's Encrypt) |
|
||||||
|
| Failover route | `rete/ha/npm-routes.conf` riga `30\|mcp.loogle.it\|8700\|...` |
|
||||||
|
| Websocket | ON |
|
||||||
|
| Force SSL | ON |
|
||||||
|
|
||||||
|
**Failover:** `npm-failover-route.sh` aggiorna upstream su Pi-2 / DS920 in base allo scenario HA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variabili d'ambiente (`.env`)
|
||||||
|
|
||||||
|
File: `/home/daniely/docker/loogle-mcp/.env`
|
||||||
|
|
||||||
|
| Variabile | Descrizione | Esempio |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `MCP_BASE_URL` | URL pubblico (issuer JWT) | `https://mcp.loogle.it` |
|
||||||
|
| `MCP_JWT_SECRET` | Segreto firma token OAuth | *(stringa lunga casuale)* |
|
||||||
|
| `MCP_OAUTH_CLIENT_ID` | Client OAuth pre-registrato | `loogle-mcp-public` |
|
||||||
|
| `PAPERLESS_URL` | API documenti | `https://docs.loogle.it` |
|
||||||
|
| `PAPERLESS_API_TOKEN` | Token API admin (fallback) | vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md) |
|
||||||
|
| `PAPERLESS_API_TOKEN_{USER}` | Token API per utente | `DANIELE`, `LUCIA`, `DAVIDE`, `LUCA` |
|
||||||
|
| `QDRANT_URL` | Vector DB remoto | `http://192.168.128.100:6333` |
|
||||||
|
| `OLLAMA_URL` | Embedding locale | `http://192.168.128.100:11434` |
|
||||||
|
| `OLLAMA_EMBED_MODEL` | Modello embedding | `nomic-embed-text` |
|
||||||
|
| `OPENAI_API_KEY` | Fallback embedding cloud | *(opzionale)* |
|
||||||
|
| `INDEXER_INTERVAL_MINUTES` | Intervallo sync Paperless | `30` |
|
||||||
|
| `THERMAL_TEMP_HARD_C` | Pausa embedding se CPU DS920 ≥ °C | `70` |
|
||||||
|
| `THERMAL_TEMP_SOFT_C` | Rallenta embedding sopra °C | `58` |
|
||||||
|
| `THERMAL_CPU_TARGET_PCT` | Cap load1 (nproc × %) | `40` |
|
||||||
|
| `OLLAMA_NUM_THREAD` | Thread CPU per richiesta embed | `1` |
|
||||||
|
| `OLLAMA_EMBED_DELAY_S` | Pausa tra embed (duty-cycle lento) | `10` |
|
||||||
|
| `DS920_THERMAL_URL` | Probe temp/load DS920 | `http://192.168.128.100:9191/thermal` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Utenti e isolamento
|
||||||
|
|
||||||
|
| Persona | Username | Ruolo | Password iniziale |
|
||||||
|
|---------|----------|-------|-------------------|
|
||||||
|
| Daniele | `daniele` | admin | `daniele` *(cambiarla)* |
|
||||||
|
| Lucia | `lucia` | user | `lucia` |
|
||||||
|
| Davide | `davide` | user | `davide` |
|
||||||
|
| Luca | `luca` | user | `luca` |
|
||||||
|
|
||||||
|
- Ogni utente vede **solo** i propri progetti in `/mnt/ha-apps/mcp/context/{username}/`
|
||||||
|
- I token OAuth JWT contengono `sub` = username; ogni tool filtra per utente
|
||||||
|
- L’admin può `reindex_document`, vedere audit completo, revocare refresh token
|
||||||
|
|
||||||
|
**Scope OAuth:** `context:read`, `context:write`, `knowledge:read`, `knowledge:write`, `gitea:read`, `gitea:write`, `home:read`, `irrigation:read`, `turni:read`, `admin` (solo daniele)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool MCP esposti
|
||||||
|
|
||||||
|
| Tool | Scope minimo | Funzione |
|
||||||
|
|------|--------------|----------|
|
||||||
|
| `ping` | — | Test connettività |
|
||||||
|
| `whoami` | — | Utente e scope attivi |
|
||||||
|
| `list_projects` | context:read | Elenco progetti |
|
||||||
|
| `create_project` | context:write | Nuovo progetto |
|
||||||
|
| `get_project_context` | context:read | Legge memoria progetto |
|
||||||
|
| `save_context` | context:write | Salva/appende memoria |
|
||||||
|
| `archive_project` | context:write | Archivia progetto |
|
||||||
|
| `search_context` | context:read | Ricerca semantica contesti |
|
||||||
|
| `search_knowledge` | knowledge:read | RAG Paperless + contesti + Gitea + app |
|
||||||
|
| `search_gitea_knowledge` | knowledge:read | RAG solo Gitea |
|
||||||
|
| `search_apps_knowledge` | knowledge:read | RAG Irrigazione + Turni |
|
||||||
|
| `list_apps_indexed` | knowledge:read | Stato indicizzazione app |
|
||||||
|
| `reindex_apps` | admin | Re-sync RAG app |
|
||||||
|
| `get_document` | knowledge:read | Testo documento Paperless |
|
||||||
|
| `list_recent_documents` | knowledge:read | Ultimi doc indicizzati |
|
||||||
|
| `reindex_document` | admin | Re-indicizza documento |
|
||||||
|
| `get_home_dashboard` | home:read | Dashboard Loogle Casa |
|
||||||
|
| `get_home_weather` | home:read | Meteo casa |
|
||||||
|
| `get_network_overview` | home:read | Panoramica rete LAN |
|
||||||
|
| `get_network_failover_status` | home:read | Stato failover cluster |
|
||||||
|
| `get_ha_entity` | home:read | Entità Home Assistant |
|
||||||
|
| `list_ha_entities` | home:read | Elenco entità HA |
|
||||||
|
| `search_ha_entities` | home:read | Cerca entità HA |
|
||||||
|
| `get_irrigation_status` | irrigation:read | Stato irrigazione |
|
||||||
|
| `get_irrigation_zones` | irrigation:read | Zone irrigazione |
|
||||||
|
| `get_irrigation_history` | irrigation:read | Storico irrigazioni |
|
||||||
|
| `get_turni_status` | — | Stato servizio Turni |
|
||||||
|
| `get_my_shifts` | turni:read | Turni utente corrente |
|
||||||
|
| `list_turni_doctors` | turni:read | Medici Turni-Live |
|
||||||
|
|
||||||
|
Vedi anche tool Gitea in `docs/GITEA-TOKEN.md` e app homelab in `docs/APPS-INTEGRATION.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage contesto (per progetto)
|
||||||
|
|
||||||
|
```
|
||||||
|
/mnt/ha-apps/mcp/context/{username}/projects/{project-id}/
|
||||||
|
meta.json # titolo, tag, date, stato, gitea_repo (opz.)
|
||||||
|
context.md # memoria persistente
|
||||||
|
sessions/ # snapshot conversazioni
|
||||||
|
artifacts/ # file generati dagli agenti
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vector store / RAG
|
||||||
|
|
||||||
|
| Collection Qdrant | Contenuto |
|
||||||
|
|-------------------|-----------|
|
||||||
|
| `kb_shared_family` | Documenti famiglia (tag non personali) |
|
||||||
|
| `kb_personal_{user}` | Documenti personali |
|
||||||
|
| `ctx_{user}` | Embedding dei contesti agente |
|
||||||
|
| `gitea_shared_family` | File testo da repo Gitea non privati |
|
||||||
|
| `gitea_personal_{user}` | File testo da repo Gitea privati dell'owner |
|
||||||
|
| `apps_shared_family` | Export Irrigazione + Turni (P7) |
|
||||||
|
|
||||||
|
**Nota Pi 5:** Qdrant locale su ARM (page size 16K) non è supportato. Default: Qdrant su DS920 o fallback SQLite in `/data/vector_fallback.db`.
|
||||||
|
|
||||||
|
**Pipeline indexer:** ogni 30 min:
|
||||||
|
1. Paperless → chunk → embedding (Ollama DS920) → upsert `kb_*`
|
||||||
|
2. Gitea (repo configurati in `GITEA_INDEX_REPOS_*`) → file `.md`/`.py`/`.sh` → chunk → upsert `gitea_*`
|
||||||
|
3. Irrigazione + Turni → snapshot/storico → upsert `apps_shared_family` (vedi `docs/APPS-INTEGRATION.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicurezza
|
||||||
|
|
||||||
|
- TLS terminato su NPM (Let's Encrypt)
|
||||||
|
- OAuth 2.1 Authorization Code + PKCE
|
||||||
|
- JWT access token 1h + refresh token 30 giorni (revocabili)
|
||||||
|
- Audit log: `{timestamp, user, tool, resource_id}` in SQLite
|
||||||
|
- CrowdSec attivo su NPM
|
||||||
|
- Nessun accesso cross-user ai dati
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operazioni comuni
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stato servizi
|
||||||
|
cd /home/daniely/docker/loogle-mcp && docker compose ps
|
||||||
|
|
||||||
|
# Log
|
||||||
|
docker logs -f loogle-mcp
|
||||||
|
docker logs -f loogle-mcp-indexer
|
||||||
|
|
||||||
|
# Redeploy
|
||||||
|
./scripts/deploy.sh
|
||||||
|
|
||||||
|
# DNS + NPM (se reinstall)
|
||||||
|
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh
|
||||||
|
|
||||||
|
# Failover route
|
||||||
|
/home/daniely/rete/scripts/npm-failover-route.sh normal
|
||||||
|
|
||||||
|
# Backup dati MCP
|
||||||
|
/home/daniely/rete/infra-monitor/mcp-restic-backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Failover (tier-b)
|
||||||
|
|
||||||
|
| Scenario | Comportamento |
|
||||||
|
|----------|---------------|
|
||||||
|
| Pi-1 down, Pi-2 up | Stack MCP su Pi-2, NPM upstream → `.81:8700` |
|
||||||
|
| Extrema DS920 | NPM3 + upstream DS920 |
|
||||||
|
| Monitoraggio | Probe `mcp_ok` in `failover-status.sh` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Da completare (post-install)
|
||||||
|
|
||||||
|
1. **`PAPERLESS_API_TOKEN_*`** in `.env` — vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md)
|
||||||
|
2. **Ollama su DS920** con `nomic-embed-text` per embedding locali
|
||||||
|
3. **Qdrant su DS920** (opzionale): `rete/compose/extrema-ds920/loogle-mcp-compose.yml`
|
||||||
|
4. **Cambio password** per tutti gli utenti dal dashboard
|
||||||
|
5. **`MCP_JWT_SECRET`** — impostare valore robusto in produzione
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Riferimenti
|
||||||
|
|
||||||
|
- [Guida utenti famiglia](docs/GUIDA-UTENTI.md)
|
||||||
|
- [Onboarding tecnico client AI](docs/ONBOARDING.md)
|
||||||
|
- [Runbook operativo](/home/daniely/rete/ha/RUNBOOK-mcp.md)
|
||||||
|
- [README progetto](README.md)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Token Gitea per Loogle MCP
|
||||||
|
|
||||||
|
## Cosa usare: Personal Access Token (PAT)
|
||||||
|
|
||||||
|
Gitea espone l'API REST su `https://git.loogle.it/api/v1/` con header:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ogni utente Gitea ha i propri repository e permessi. L'hub MCP usa un token **per utente famiglia**, come Paperless.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dove generarlo
|
||||||
|
|
||||||
|
Per ogni utente che deve usare i tool Gitea da Claude/Cursor (daniele, lucia, …):
|
||||||
|
|
||||||
|
1. Accedi a https://git.loogle.it con quell'account
|
||||||
|
2. **Impostazioni** → **Applicazioni** → **Genera nuovo token**
|
||||||
|
3. Nome suggerito: `loogle-mcp`
|
||||||
|
4. Scope minimi:
|
||||||
|
- `read:repository` — list_repos, get_file, search_code, list_issues, get_issue, RAG
|
||||||
|
- `write:repository` — **P4:** create_or_update_file
|
||||||
|
- `write:user` — **P4:** create_gitea_repo (`POST /user/repos`)
|
||||||
|
- `write:issue` — create_issue
|
||||||
|
- `read:user` — consigliato per `/user/repos`
|
||||||
|
5. Copia il token (mostrato una sola volta) in `.env`
|
||||||
|
|
||||||
|
### Alternativa CLI (admin, sul nodo con Gitea attivo)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -u git gitea gitea admin user generate-access-token \
|
||||||
|
--username daniele \
|
||||||
|
--token-name loogle-mcp \
|
||||||
|
--scopes 'read:repository,write:repository,write:issue,write:user,read:user'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configurazione in Loogle MCP
|
||||||
|
|
||||||
|
Modifica `/home/daniely/docker/loogle-mcp/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GITEA_URL=https://git.loogle.it
|
||||||
|
GITEA_API_TOKEN_DANIELE=
|
||||||
|
GITEA_API_TOKEN_LUCIA=
|
||||||
|
GITEA_API_TOKEN_DAVIDE=
|
||||||
|
GITEA_API_TOKEN_LUCA=
|
||||||
|
```
|
||||||
|
|
||||||
|
| Utente MCP | Variabile `.env` | Account Gitea |
|
||||||
|
|------------|------------------|---------------|
|
||||||
|
| Daniele | `GITEA_API_TOKEN_DANIELE` | daniele |
|
||||||
|
| Lucia | `GITEA_API_TOKEN_LUCIA` | lucia |
|
||||||
|
| Davide | `GITEA_API_TOKEN_DAVIDE` | davide |
|
||||||
|
| Luca | `GITEA_API_TOKEN_LUCA` | luca |
|
||||||
|
|
||||||
|
### URL API interno (opzionale)
|
||||||
|
|
||||||
|
Se `https://git.loogle.it` non è raggiungibile dal container MCP (rete LAN, failover), imposta un URL diretto:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GITEA_API_URL=http://192.168.128.81:3002
|
||||||
|
```
|
||||||
|
|
||||||
|
I link nei risultati restano su `GITEA_URL` pubblico.
|
||||||
|
|
||||||
|
### Fallback admin
|
||||||
|
|
||||||
|
```env
|
||||||
|
GITEA_API_TOKEN=<token-admin>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tutti gli utenti MCP useranno lo stesso token (sconsigliato salvo test).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool MCP Gitea
|
||||||
|
|
||||||
|
| Tool | Scope | Descrizione |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| `list_repos` | gitea:read | Repository accessibili |
|
||||||
|
| `get_file` | gitea:read | Legge file o elenca directory |
|
||||||
|
| `search_code` | gitea:read | Cerca nel codice (API `/search/code` o fallback tree) |
|
||||||
|
| `list_issues` | gitea:read | Issue aperte/chiuse |
|
||||||
|
| `get_issue` | gitea:read | Dettaglio issue |
|
||||||
|
| `create_issue` | gitea:write | Crea issue |
|
||||||
|
| `create_gitea_repo` | gitea:write | Crea repo sotto l'utente MCP corrente |
|
||||||
|
| `create_or_update_file` | gitea:write | Commit singolo di un file (create/update via Contents API) |
|
||||||
|
|
||||||
|
Dopo modifica `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/daniely/docker/loogle-mcp && docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verifica
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/daniely/docker/loogle-mcp/scripts/test_gitea_integration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Oppure da MCP (con token OAuth): tool `list_repos` e `get_file` su `daniele/rete`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collegamento progetto ↔ repository (P2)
|
||||||
|
|
||||||
|
Ogni progetto MCP può referenziare un repo Gitea in `meta.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"gitea_repo": "daniele/rete"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool
|
||||||
|
|
||||||
|
| Tool | Azione |
|
||||||
|
|------|--------|
|
||||||
|
| `create_project` | Parametri opzionali `gitea_repo`, `seed_from_gitea` |
|
||||||
|
| `link_project_repo` | Collega, cambia repo o scollega (`gitea_repo` vuoto) |
|
||||||
|
| `get_project_context` | Aggiunge blocco `gitea` con README, `docs/` e issue aperte |
|
||||||
|
|
||||||
|
### Seed README
|
||||||
|
|
||||||
|
Con `seed_from_gitea: true` il README del repo viene importato in `context.md` (una sola volta, marker `<!-- seed:gitea ... -->`).
|
||||||
|
|
||||||
|
### Verifica P2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/daniely/docker/loogle-mcp/scripts/test_project_gitea_p2.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RAG su repository Gitea (P3)
|
||||||
|
|
||||||
|
Il worker `loogle-mcp-indexer` indicizza periodicamente file testo dai repo Gitea (`.md`, `.py`, `.sh`, …) in collection Qdrant dedicate:
|
||||||
|
|
||||||
|
| Collection | Contenuto |
|
||||||
|
|------------|-----------|
|
||||||
|
| `gitea_shared_family` | Repo non privati |
|
||||||
|
| `gitea_personal_{user}` | Repo privati dell'owner |
|
||||||
|
|
||||||
|
### Config `.env`
|
||||||
|
|
||||||
|
```env
|
||||||
|
GITEA_INDEX_ENABLED=yes
|
||||||
|
GITEA_INDEX_REPOS_DANIELE=daniele/rete,daniele/loogle-scripts
|
||||||
|
GITEA_INDEX_MAX_FILES_PER_REPO=150
|
||||||
|
GITEA_INDEX_MAX_FILE_BYTES=120000
|
||||||
|
```
|
||||||
|
|
||||||
|
Se `GITEA_INDEX_REPOS_{USER}` è vuoto → tutti i repo accessibili via API.
|
||||||
|
|
||||||
|
### Tool MCP
|
||||||
|
|
||||||
|
| Tool | Descrizione |
|
||||||
|
|------|-------------|
|
||||||
|
| `search_gitea_knowledge` | Ricerca semantica solo su Gitea |
|
||||||
|
| `search_knowledge` | Include Paperless + contesti + Gitea |
|
||||||
|
| `list_gitea_indexed_files` | Stato indicizzazione |
|
||||||
|
| `reindex_gitea_repo` | Forza re-index di un repo |
|
||||||
|
|
||||||
|
### Verifica P3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec loogle-mcp python3 /srv/scripts/test_gitea_rag_p3.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scrittura su Gitea (P4)
|
||||||
|
|
||||||
|
L'AI può creare repository e committare singoli file via API Gitea (senza git push locale).
|
||||||
|
|
||||||
|
| Tool | Scope | Azione |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `create_gitea_repo` | gitea:write | Crea repo sotto l'utente MCP autenticato |
|
||||||
|
| `create_or_update_file` | gitea:write | Crea o aggiorna un file (Contents API + commit) |
|
||||||
|
|
||||||
|
Regole:
|
||||||
|
|
||||||
|
- Scrittura consentita solo su repo di cui l'utente è **owner** (salvo admin MCP).
|
||||||
|
- Dopo scrittura, opzionalmente re-indicizza il file nel RAG (`reindex: true` default).
|
||||||
|
- Token Gitea: `write:user` (create repo) + `write:repository` (file).
|
||||||
|
|
||||||
|
### Workspace davide e luca
|
||||||
|
|
||||||
|
| Utente | Repo Gitea | Progetto MCP |
|
||||||
|
|--------|------------|--------------|
|
||||||
|
| davide | `davide/progetti` | Progetti Davide |
|
||||||
|
| luca | `luca/progetti` | Progetti Luca |
|
||||||
|
|
||||||
|
Setup iniziale:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec loogle-mcp python3 /srv/scripts/setup_gitea_p4_repos.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verifica P4
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec loogle-mcp python3 /srv/scripts/test_gitea_p4.py
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Guida amministratore — Daniele
|
||||||
|
|
||||||
|
Guida operativa per gestire e usare Loogle MCP Hub.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accesso rapido
|
||||||
|
|
||||||
|
| Cosa | URL / comando |
|
||||||
|
|------|----------------|
|
||||||
|
| Dashboard admin | https://mcp.loogle.it/dashboard (login `daniele`) |
|
||||||
|
| Health | `curl -sf https://mcp.loogle.it/health` |
|
||||||
|
| Log gateway | `docker logs -f loogle-mcp` |
|
||||||
|
| Log indexer | `docker logs -f loogle-mcp-indexer` |
|
||||||
|
| Compose | `cd /home/daniely/docker/loogle-mcp` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configurazione iniziale (checklist)
|
||||||
|
|
||||||
|
- [x] Stack Docker avviato (`docker compose up -d`)
|
||||||
|
- [x] DNS Pi-hole: `mcp.loogle.it` → `192.168.128.85`
|
||||||
|
- [x] NPM proxy host 30 + TLS Let's Encrypt
|
||||||
|
- [ ] Token Paperless in `.env` — vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md) (Profilo utente, non «Applicazione social»)
|
||||||
|
- [ ] `MCP_JWT_SECRET` robusto in `.env`
|
||||||
|
- [ ] Ollama su DS920 con `ollama pull nomic-embed-text`
|
||||||
|
- [ ] Qdrant su DS920 (opzionale, vedi compose extrema)
|
||||||
|
- [ ] Password cambiate per tutta la famiglia
|
||||||
|
- [ ] Ogni familiare ha collegato almeno un client AI
|
||||||
|
|
||||||
|
**Script utili:**
|
||||||
|
```bash
|
||||||
|
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh # DNS + NPM
|
||||||
|
./scripts/deploy.sh # rebuild + restart
|
||||||
|
/home/daniely/rete/scripts/npm-failover-route.sh normal # upstream failover
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Come usare tu (Daniele)
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
1. https://mcp.loogle.it/dashboard — login `daniele`
|
||||||
|
2. Vedi progetti, audit log completo, revoca refresh token (sezione admin)
|
||||||
|
|
||||||
|
### Con Cursor / Claude / ChatGPT
|
||||||
|
|
||||||
|
Stesso URL MCP: `https://mcp.loogle.it/mcp`
|
||||||
|
|
||||||
|
**Cursor** — aggiungi in `.cursor/mcp.json` del progetto:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"loogle-mcp": {
|
||||||
|
"url": "https://mcp.loogle.it/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow consigliato per un task
|
||||||
|
|
||||||
|
1. **create_project** — es. «Homelab MCP», tag `rete`, `2026`
|
||||||
|
2. Lavori con l’AI (Cursor, Claude, ecc.)
|
||||||
|
3. **save_context** — a fine sessione salvi decisioni e stato
|
||||||
|
4. **search_knowledge** — recuperi doc da Paperless quando serve
|
||||||
|
5. **search_context** — ritrovi discussioni passate semanticamente
|
||||||
|
|
||||||
|
### Digest giornaliero agenti → `homelab-loogle`
|
||||||
|
|
||||||
|
Ogni sera (cron `22:45` su Pi-1) lo script `scripts/export_daily_agent_digest.py` aggiorna un blocco `<!-- daily-digest:YYYY-MM-DD -->` sul progetto **homelab-loogle**, con:
|
||||||
|
|
||||||
|
- query e conclusioni dagli agent transcript Cursor
|
||||||
|
- tool MCP usati (audit_log)
|
||||||
|
- commit git del giorno (`rete`, `loogle-mcp`)
|
||||||
|
- sync `~/.cursor/plans/*.plan.md` → `artifacts/plans/` + indice in `context.md` (solo i piani toccati quel giorno compaiono nel digest)
|
||||||
|
|
||||||
|
Claude (e Cursor) lo leggono con `get_project_context` su `homelab-loogle`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/daniely/docker/loogle-mcp
|
||||||
|
python3 scripts/export_daily_agent_digest.py --dry-run
|
||||||
|
python3 scripts/export_daily_agent_digest.py --force --no-index
|
||||||
|
# opzionale: indicizza il blocco in Qdrant ctx_* (più lento, Ollama)
|
||||||
|
python3 scripts/export_daily_agent_digest.py --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Log: `data/daily-digest.log`. Idempotente; `--force` sovrascrive il giorno.
|
||||||
|
|
||||||
|
### Tool solo admin
|
||||||
|
|
||||||
|
- `reindex_document` — forza re-indicizzazione di un doc Paperless
|
||||||
|
- Revoca token refresh da dashboard
|
||||||
|
- Audit log di tutti gli utenti via `/api/audit`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gestione utenti
|
||||||
|
|
||||||
|
Gli utenti sono in SQLite (`loogle_mcp.db`). Password hash PBKDF2.
|
||||||
|
|
||||||
|
| Utente | Admin | Reset password |
|
||||||
|
|--------|-------|----------------|
|
||||||
|
| daniele | sì | dashboard o diretto DB |
|
||||||
|
| lucia, davide, luca | no | dashboard (self-service) |
|
||||||
|
|
||||||
|
Password iniziale = username. `must_change_password` è attivo al primo login consigliato.
|
||||||
|
|
||||||
|
Per aggiungere utenti in futuro: estendi `FAMILY_USERS` in `app/auth.py` e redeploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paperless → knowledge base
|
||||||
|
|
||||||
|
Vedi guida completa: [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md)
|
||||||
|
|
||||||
|
**Non usare** i token «Applicazione social» dell'admin Django (schermata nell'immagine): servono al login OAuth web di Paperless, non all'API REST MCP.
|
||||||
|
|
||||||
|
**Usa** i token API utente: docs.loogle.it → menu utente → **Profilo** → pulsante freccia circolare accanto a «Token API».
|
||||||
|
|
||||||
|
Configura in `.env` (un token per ogni account Paperless della famiglia):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PAPERLESS_API_TOKEN_DANIELE=<token account Daniele>
|
||||||
|
PAPERLESS_API_TOKEN_LUCIA=<token account Lucia>
|
||||||
|
PAPERLESS_API_TOKEN_DAVIDE=<token account Davide>
|
||||||
|
PAPERLESS_API_TOKEN_LUCA=<token account Luca>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ogni familiare genera il proprio token da docs.loogle.it → **Profilo** → Token API. Se un utente non ha ancora account Paperless, lascia la riga vuota finché non lo crei.
|
||||||
|
|
||||||
|
Alternativa: solo `PAPERLESS_API_TOKEN=` con token admin (Daniele superuser) — tutti gli utenti MCP condividono lo stesso scope.
|
||||||
|
|
||||||
|
Riavvia indexer:
|
||||||
|
```bash
|
||||||
|
cd /home/daniely/docker/loogle-mcp && docker compose restart loogle-mcp loogle-mcp-indexer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visibilità documenti (tag Paperless):**
|
||||||
|
- Tag `personal` / `privato` → solo collection personale del owner
|
||||||
|
- Senza tag personali → `kb_shared_family` (tutti in famiglia)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Embedding e Qdrant
|
||||||
|
|
||||||
|
| Componente | Dove | Note |
|
||||||
|
|----------|------|------|
|
||||||
|
| Ollama | DS920 `:11434` | `OLLAMA_EMBED_MODEL=nomic-embed-text` |
|
||||||
|
| Qdrant | DS920 `:6333` | Non gira su Pi5 (limitazione ARM) |
|
||||||
|
| Fallback | `/data/vector_fallback.db` | Automatico se Qdrant non raggiungibile |
|
||||||
|
|
||||||
|
Deploy Qdrant su DS920:
|
||||||
|
```bash
|
||||||
|
# sul DS920, path mirror extrema
|
||||||
|
docker compose -f /volume1/extrema/compose/extrema-ds920/loogle-mcp-compose.yml up -d qdrant
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DNS e NPM — cosa è stato configurato
|
||||||
|
|
||||||
|
**Pi-hole** (`/etc/pihole/pihole.toml`):
|
||||||
|
```
|
||||||
|
"192.168.128.85 mcp.loogle.it"
|
||||||
|
```
|
||||||
|
|
||||||
|
**NPM** proxy host ID 30:
|
||||||
|
- Dominio: `mcp.loogle.it`
|
||||||
|
- Backend: `192.168.128.80:8700`
|
||||||
|
- Cert: `npm-65`
|
||||||
|
|
||||||
|
**Failover** (`npm-routes.conf`):
|
||||||
|
```
|
||||||
|
30|mcp.loogle.it|8700|pi1|pi1|local|ds920
|
||||||
|
```
|
||||||
|
|
||||||
|
Se reinstalli NPM da zero, riesegui:
|
||||||
|
```bash
|
||||||
|
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh
|
||||||
|
/home/daniely/sync-npm-full.sh # sync Pi2 + DS920
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoraggio e backup
|
||||||
|
|
||||||
|
- **Failover status:** probe `mcp_ok` / `mcp_be` in `failover-status.sh`
|
||||||
|
- **Runbook incidenti:** `/home/daniely/rete/ha/RUNBOOK-mcp.md`
|
||||||
|
- **Backup restic:** `/home/daniely/rete/infra-monitor/mcp-restic-backup.sh`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Onboarding famiglia
|
||||||
|
|
||||||
|
Invia a ciascuno:
|
||||||
|
|
||||||
|
- [Guida utenti](GUIDA-UTENTI.md) — istruzioni semplici per ChatGPT/Claude/Gemini
|
||||||
|
- URL dashboard per cambio password
|
||||||
|
- Username personale (non condividere password)
|
||||||
|
|
||||||
|
Progetto demo già creato per ogni utente (es. `progetti-personali` per Lucia).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentazione tecnica
|
||||||
|
|
||||||
|
- [Architettura e parametri](ARCHITETTURA.md)
|
||||||
|
- [Onboarding client AI (dettaglio)](ONBOARDING.md)
|
||||||
|
- [Runbook HA](/home/daniely/rete/ha/RUNBOOK-mcp.md)
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# Paperless — Guida per la famiglia LOOGLE
|
||||||
|
|
||||||
|
**Archivio documenti di casa:** https://docs.loogle.it
|
||||||
|
|
||||||
|
Questa guida spiega **perché** usiamo Paperless, **come** configurarlo e **come** installare l’app sul telefono.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Perché usiamo Paperless
|
||||||
|
|
||||||
|
Paperless è l’**archivio digitale di casa**: bollette, contratti, manuali, referti, ricevute, documenti scolastici, ecc.
|
||||||
|
|
||||||
|
| Prima (cartaceo) | Con Paperless |
|
||||||
|
|------------------|---------------|
|
||||||
|
| Documenti sparsi in cassetti e cartelle | Tutto in un unico archivio searchable |
|
||||||
|
| Difficile trovare «la bolletta luce del 2024» | Cerca per parola, tag, mittente, data |
|
||||||
|
| Fotocopie e PDF persi nel telefono | PDF centralizzati, backup su NAS di casa |
|
||||||
|
| Solo chi ha il foglio fisico | Chi ha l’account può consultare (con permessi) |
|
||||||
|
|
||||||
|
**Vantaggi per noi:**
|
||||||
|
|
||||||
|
- **Resta in casa** — i file sono sui nostri dispositivi (Pi + NAS), non su Google Drive o iCloud di terzi
|
||||||
|
- **OCR in italiano** — Paperless legge il testo nei PDF e nelle scansioni, così puoi cercare anche dentro il documento
|
||||||
|
- **Integrazione** — l’assistente AI (MCP) può cercare documenti già archiviati quando chiedi «trova la bolletta gas»
|
||||||
|
- **Accesso da telefono** — consulta e carica documenti ovunque (in rete casa o via VPN)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cos’è, in pratica
|
||||||
|
|
||||||
|
Paperless-ngx è un sito web dove:
|
||||||
|
|
||||||
|
1. **Carichi** un PDF o una foto (upload, email, cartella sul telefono)
|
||||||
|
2. **Paperless** fa OCR, propone titolo, data, tag
|
||||||
|
3. **Tu** confermi o correggi (opzionale)
|
||||||
|
4. **Archivi** — il documento resta indicizzato e ricercabile
|
||||||
|
|
||||||
|
**Indirizzo:** https://docs.loogle.it
|
||||||
|
Funziona da browser (PC, tablet, telefono) o da app dedicata.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Account famiglia
|
||||||
|
|
||||||
|
| Nome | Username | Password iniziale |
|
||||||
|
|------|----------|-------------------|
|
||||||
|
| Daniele | `daniele` | *(chiedi a Daniele)* |
|
||||||
|
| Lucia | `lucia` | `lucia` |
|
||||||
|
| Davide | `davide` | `davide` |
|
||||||
|
| Luca | `luca` | `luca` |
|
||||||
|
|
||||||
|
**Al primo accesso cambia la password** (vedi sotto).
|
||||||
|
|
||||||
|
Ogni persona vede i **propri** documenti e quelli **condivisi in famiglia** (secondo i permessi impostati).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Primo accesso e configurazione (browser)
|
||||||
|
|
||||||
|
### 1. Accedi al sito
|
||||||
|
|
||||||
|
1. Apri **https://docs.loogle.it** (da casa o in VPN — vedi sotto)
|
||||||
|
2. Inserisci **username** e **password**
|
||||||
|
3. Se è la prima volta, Paperless può chiederti di completare il profilo
|
||||||
|
|
||||||
|
### 2. Cambia la password
|
||||||
|
|
||||||
|
1. Clicca sul **nome utente** in alto a destra
|
||||||
|
2. Vai su **Profilo** / **Impostazioni**
|
||||||
|
3. Sezione **Password** → inserisci la vecchia e la nuova
|
||||||
|
4. Salva
|
||||||
|
|
||||||
|
### 3. Conosci l’interfaccia
|
||||||
|
|
||||||
|
| Area | A cosa serve |
|
||||||
|
|------|----------------|
|
||||||
|
| **Dashboard** | Panoramica, documenti recenti |
|
||||||
|
| **Documenti** | Elenco e ricerca di tutti i PDF |
|
||||||
|
| **Inbox** (se attiva) | Documenti appena caricati da revisionare |
|
||||||
|
| **Tag / Tipi / Corrispondenti** | Organizzazione (es. tag `bollette`, `salute`) |
|
||||||
|
|
||||||
|
### 4. Caricare un documento
|
||||||
|
|
||||||
|
**Da computer:**
|
||||||
|
|
||||||
|
1. **Documenti** → **Carica** (o trascina il PDF nella finestra)
|
||||||
|
2. Attendi l’OCR (qualche secondo)
|
||||||
|
3. Controlla titolo, data, tag → **Salva**
|
||||||
|
|
||||||
|
**Da telefono (browser):**
|
||||||
|
|
||||||
|
- Stesso procedimento dal browser mobile su docs.loogle.it
|
||||||
|
|
||||||
|
**Suggerimento:** usa tag semplici e coerenti, es. `bollette`, `casa`, `auto`, `salute`, `scuola`.
|
||||||
|
|
||||||
|
### 5. Cercare un documento
|
||||||
|
|
||||||
|
- Barra **Cerca** in alto: parole chiave (es. «enel», «bolletta», «assicurazione»)
|
||||||
|
- Filtri per **tag**, **tipo documento**, **data**
|
||||||
|
- Paperless cerca anche **dentro** il testo del PDF (OCR)
|
||||||
|
|
||||||
|
### 6. Scaricare o condividere
|
||||||
|
|
||||||
|
- Apri un documento → icona **Download** o **Condividi**
|
||||||
|
- Puoi inviare il PDF via email/WhatsApp dal telefono
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accesso da fuori casa (VPN)
|
||||||
|
|
||||||
|
`docs.loogle.it` è raggiungibile **in rete di casa** (Wi‑Fi domestico o dati se sei a casa).
|
||||||
|
|
||||||
|
**Se sei fuori** (ufficio, viaggio): connettiti prima alla **VPN di casa**:
|
||||||
|
|
||||||
|
- Indirizzo: **https://vpn.loogle.it**
|
||||||
|
- Credenziali WireGuard: chiedi a Daniele (file `.conf` o QR code)
|
||||||
|
|
||||||
|
Dopo la VPN, apri normalmente https://docs.loogle.it o l’app Paperless.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App sul telefono
|
||||||
|
|
||||||
|
Paperless **non ha un’app ufficiale** del team Paperless-ngx, ma esistono app **compatibili** che si collegano al nostro server.
|
||||||
|
|
||||||
|
### Consigliate
|
||||||
|
|
||||||
|
| App | Android | iPhone/iPad |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| **Paperless Mobile** | [Google Play](https://play.google.com/store/apps/details?id=de.astubenbord.paperless_mobile) | Cerca «Paperless Mobile» su App Store |
|
||||||
|
| **Paperless Go** | [Google Play](https://play.google.com/store/apps/details?id=com.github.iweinzierl.paperlessgo) / F-Droid | [App Store](https://apps.apple.com/app/paperless-go/id6738696198) |
|
||||||
|
|
||||||
|
Per la famiglia consigliamo **Paperless Mobile** (molto usata, italiano supportato) oppure **Paperless Go** (interfaccia moderna, scansione documenti).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installazione app — passo passo
|
||||||
|
|
||||||
|
### Android (Paperless Mobile)
|
||||||
|
|
||||||
|
1. Apri **Google Play Store**
|
||||||
|
2. Cerca **«Paperless Mobile»** (sviluppatore: Andrei Stübenbord)
|
||||||
|
3. **Installa**
|
||||||
|
4. Apri l’app → **Aggiungi account** / **Connect**
|
||||||
|
5. **URL server:** `https://docs.loogle.it`
|
||||||
|
6. **Username** e **password** (le tue credenziali Paperless)
|
||||||
|
7. Accetta eventuale avviso certificato (connessione sicura LOOGLE)
|
||||||
|
8. Fatto — vedi l’elenco documenti
|
||||||
|
|
||||||
|
### iPhone / iPad (Paperless Mobile o Paperless Go)
|
||||||
|
|
||||||
|
1. Apri **App Store**
|
||||||
|
2. Cerca **«Paperless Mobile»** o **«Paperless Go»**
|
||||||
|
3. **Scarica** e apri
|
||||||
|
4. Inserisci:
|
||||||
|
- **Server URL:** `https://docs.loogle.it`
|
||||||
|
- **Username** / **Password**
|
||||||
|
5. Opzionale: attiva **Face ID / Touch ID** per proteggere l’app
|
||||||
|
6. Fatto
|
||||||
|
|
||||||
|
### Scansione con il telefono (Paperless Go)
|
||||||
|
|
||||||
|
1. Nell’app → **Scansiona** / icona fotocamera
|
||||||
|
2. Inquadra il documento (bolletta, lettera)
|
||||||
|
3. Ritaglia e conferma
|
||||||
|
4. Scegli tag / tipo se richiesto → **Carica**
|
||||||
|
5. Il PDF compare in Paperless dopo pochi secondi
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configurazione consigliata nell’app
|
||||||
|
|
||||||
|
| Impostazione | Consiglio |
|
||||||
|
|--------------|-----------|
|
||||||
|
| **Tema scuro** | Più comodo di sera |
|
||||||
|
| **Biometria** | Face ID / impronta per aprire l’app |
|
||||||
|
| **Notifiche** | Opzionali (nuovi documenti in inbox) |
|
||||||
|
| **Account multipli** | Un account per persona (non condividere la stessa login) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Buone abitudini
|
||||||
|
|
||||||
|
1. **Carica subito** bollette e documenti importanti (non lasciare pile di carta)
|
||||||
|
2. **Usa tag** — pochi ma chiari (`luce`, `gas`, `banca`, `medico`)
|
||||||
|
3. **Controlla l’inbox** — correggi titolo/data se Paperless sbaglia
|
||||||
|
4. **Non eliminare** l’originale cartaceo finché non sei sicuro che il PDF sia ok
|
||||||
|
5. **Cambia password** se pensi che qualcuno l’abbia vista
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collegamento con l’assistente AI (MCP)
|
||||||
|
|
||||||
|
Se usi ChatGPT, Claude o Cursor collegati a **Loogle MCP** (`https://mcp.loogle.it`):
|
||||||
|
|
||||||
|
- Puoi chiedere: *«Cerca in Paperless la bolletta luce dell’ultimo trimestre»*
|
||||||
|
- L’AI cerca solo nei documenti **che il tuo account può vedere**
|
||||||
|
- Per funzionare, i documenti devono **già essere** in Paperless
|
||||||
|
|
||||||
|
La guida MCP è separata: `GUIDA-UTENTI.pdf` nella stessa cartella.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problemi comuni
|
||||||
|
|
||||||
|
| Problema | Cosa fare |
|
||||||
|
|----------|-----------|
|
||||||
|
| «Sito non raggiungibile» | Sei in rete casa? Se no, attiva **VPN** (vpn.loogle.it) |
|
||||||
|
| Login fallisce | Controlla username/password; prova da browser prima |
|
||||||
|
| App «connessione fallita» | URL esatto: `https://docs.loogle.it` (con **https**) |
|
||||||
|
| Certificato / SSL | Accetta il certificato LOOGLE; data/ora telefono corretta |
|
||||||
|
| Non vedo un documento | Potrebbe essere personale di un altro utente; chiedi a Daniele |
|
||||||
|
| OCR illeggibile | Scansiona con buona luce; PDF meglio di foto storta |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contatti
|
||||||
|
|
||||||
|
**Daniele** — account, permessi, VPN, problemi tecnici.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scheda rapida
|
||||||
|
|
||||||
|
```
|
||||||
|
Sito: https://docs.loogle.it
|
||||||
|
VPN fuori: https://vpn.loogle.it
|
||||||
|
App Android: Paperless Mobile (Play Store)
|
||||||
|
App iPhone: Paperless Mobile o Paperless Go (App Store)
|
||||||
|
Server URL: https://docs.loogle.it
|
||||||
|
Login: il tuo username (lucia / davide / luca)
|
||||||
|
Primo passo: cambia password → installa app → carica una bolletta di prova
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Loogle — documenti di famiglia, a casa nostra.*
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
# Guida utenti — Loogle MCP Hub
|
||||||
|
|
||||||
|
Questa guida è per **Lucia, Davide e Luca** (e chiunque usi gli assistenti AI collegati a casa).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cos’è
|
||||||
|
|
||||||
|
Loogle MCP è la **memoria di casa** per i tuoi assistenti AI. Permette a ChatGPT, Claude o Gemini di:
|
||||||
|
|
||||||
|
- **Ricordare** i tuoi progetti (es. ristrutturazione, studio, hobby)
|
||||||
|
- **Cercare** documenti che abbiamo già archiviato in Paperless (bollette, manuali, PDF)
|
||||||
|
- **Salvare** note e riassunti che l’AI produce durante un lavoro, così non devi ripetere tutto ogni volta
|
||||||
|
|
||||||
|
I tuoi dati restano **solo sui nostri dispositivi** a casa, non nel cloud dell’AI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Le tue credenziali
|
||||||
|
|
||||||
|
| Nome | Username | Password iniziale |
|
||||||
|
|------|----------|-------------------|
|
||||||
|
| Lucia | `lucia` | `lucia` |
|
||||||
|
| Davide | `davide` | `davide` |
|
||||||
|
| Luca | `luca` | `luca` |
|
||||||
|
|
||||||
|
**Al primo accesso cambia la password:**
|
||||||
|
|
||||||
|
1. Apri https://mcp.loogle.it/dashboard
|
||||||
|
2. Accedi con username e password
|
||||||
|
3. Sezione **Cambia password** → inserisci la nuova password (minimo 6 caratteri)
|
||||||
|
|
||||||
|
Ogni persona vede **solo i propri progetti**. Non puoi vedere (né l’AI) i progetti degli altri familiari.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collegare ChatGPT
|
||||||
|
|
||||||
|
Richiede account **Plus, Pro, Business o Edu**.
|
||||||
|
|
||||||
|
1. Apri ChatGPT → **Impostazioni**
|
||||||
|
2. Attiva **Developer Mode** (se non già attivo)
|
||||||
|
3. Vai a **Connectors** → **Add MCP Connector**
|
||||||
|
4. Inserisci l’URL del server:
|
||||||
|
```
|
||||||
|
https://mcp.loogle.it/mcp
|
||||||
|
```
|
||||||
|
5. Clicca **Connect** — si apre una pagina di login Loogle
|
||||||
|
6. Accedi con **il tuo** username e password (es. `lucia` / la tua nuova password)
|
||||||
|
7. Autorizza l’accesso
|
||||||
|
|
||||||
|
Nelle conversazioni, abilita i tool del connector **Loogle MCP** quando vuoi usare memoria o documenti.
|
||||||
|
|
||||||
|
### Esempi di richieste a ChatGPT
|
||||||
|
|
||||||
|
- *«Usa list_projects per vedere i miei progetti»*
|
||||||
|
- *«Leggi il contesto del progetto X e riassumilo»*
|
||||||
|
- *«Cerca nei documenti di casa informazioni sulla caldaia»*
|
||||||
|
- *«Salva in save_context questo riepilogo del lavoro fatto oggi»*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collegare Claude (App / Desktop)
|
||||||
|
|
||||||
|
1. Apri Claude → **Settings** → **Connectors**
|
||||||
|
2. **Add remote MCP server**
|
||||||
|
3. URL:
|
||||||
|
```
|
||||||
|
https://mcp.loogle.it/mcp
|
||||||
|
```
|
||||||
|
4. Al primo utilizzo: login con le **tue** credenziali Loogle MCP
|
||||||
|
5. Autorizza
|
||||||
|
|
||||||
|
Claude potrà usare gli stessi tool (progetti, ricerca documenti, salvataggio contesto).
|
||||||
|
|
||||||
|
### Far usare MCP ogni giorno (Desktop + mobile)
|
||||||
|
|
||||||
|
Claude **non** salva il contesto da solo: va istruito una volta e poi richiamato con abitudini brevi.
|
||||||
|
|
||||||
|
**1. Istruzioni personalizzate (Desktop e App — stesso account)**
|
||||||
|
Settings → **Profile** / **Custom instructions** (o *What should Claude know about you?*) e incolla:
|
||||||
|
|
||||||
|
```
|
||||||
|
Hai il connector Loogle MCP (mcp.loogle.it). Per lavori su casa, documenti, progetti o codice famiglia:
|
||||||
|
- All’inizio: list_projects → get_project_context sul progetto rilevante.
|
||||||
|
- Per documenti/bollette/manuali: search_knowledge (non inventare).
|
||||||
|
- Per codice/runbook Gitea: search_gitea_knowledge o get_file.
|
||||||
|
- A fine chat utile: save_context (append) con decisioni e next step.
|
||||||
|
Se non esiste un progetto adatto, create_project prima di salvare.
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Abilita sempre i tool del connector**
|
||||||
|
In ogni chat nuova, assicurati che **Loogle MCP** sia attivo nei tool/connectors (su Desktop a volte va riacceso per conversazione).
|
||||||
|
|
||||||
|
**3. Frasi-ancora (funzionano anche da mobile)**
|
||||||
|
Usa all’inizio o alla fine, senza ricordare i nomi tool:
|
||||||
|
|
||||||
|
- *«Controlla prima su Loogle MCP il mio progetto e i documenti rilevanti.»*
|
||||||
|
- *«A fine risposta salva su MCP un riepilogo nel progetto giusto.»*
|
||||||
|
- *«Cerca in Paperless via MCP la bolletta / il manuale …»*
|
||||||
|
- *«Come sta l’irrigazione / i turni oggi? Usa MCP.»*
|
||||||
|
|
||||||
|
**4. Un progetto = un tema**
|
||||||
|
Es. «Rinnovo bagno», «Homelab», «Studio». Più il `context.md` è pieno di decisioni reali, più Claude lo riuserà da solo.
|
||||||
|
|
||||||
|
**5. Mobile**
|
||||||
|
Stesso account = stesse custom instructions. MCP remoto dipende dal supporto app Claude; se i tool non compaiono, usa Desktop/web per i salvataggi e da mobile chiedi almeno *«ricorda di aggiornare MCP quando torno al computer»* oppure ripeti la frase-ancora quando i connector sono disponibili.
|
||||||
|
|
||||||
|
**6. Verifica**
|
||||||
|
Dashboard https://mcp.loogle.it/dashboard → ultime azioni: devono comparire `get_project_context` / `save_context` / `search_knowledge` dopo le chat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Collegare Cursor IDE
|
||||||
|
|
||||||
|
1. Crea o modifica `~/.cursor/mcp.json` (globale) oppure `.cursor/mcp.json` nel progetto:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"loogle-mcp": {
|
||||||
|
"url": "https://mcp.loogle.it/mcp",
|
||||||
|
"auth": {
|
||||||
|
"CLIENT_ID": "cursor",
|
||||||
|
"scopes": [
|
||||||
|
"context:read", "context:write",
|
||||||
|
"knowledge:read", "knowledge:write",
|
||||||
|
"gitea:read", "gitea:write",
|
||||||
|
"home:read", "irrigation:read", "turni:read"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Riavvia Cursor completamente** (esci dall’app, non solo chiudi la finestra)
|
||||||
|
3. **Cursor Settings** → **Tools & MCP** → **Connect** su `loogle-mcp` (**una sola volta**, attendi 10–15 s)
|
||||||
|
4. Si apre il browser su **Loogle MCP — Login** → accedi con le **tue** credenziali MCP
|
||||||
|
5. In chat, chiedi all'AI di usare i tool (`list_projects`, `search_knowledge`, …)
|
||||||
|
|
||||||
|
### Cursor: «Unauthorized» nei log
|
||||||
|
|
||||||
|
| Log Cursor | Significato |
|
||||||
|
|------------|-------------|
|
||||||
|
| `MCP OAuth redirect` + `Unauthorized` | **Normale prima del login** — clicca **Connect** e completa il browser |
|
||||||
|
| `Redirect URI non consentito` | Errore server (già risolto) — riprova **Reconnect** |
|
||||||
|
| `credentials cleared` | Hai cliccato Connect/Disconnect in loop — riavvia Cursor e riprova una volta |
|
||||||
|
|
||||||
|
**Password:** se hai cambiato quella iniziale, usa quella attuale (es. admin `daniele` non usa più `daniele`/`daniele`). Verifica su https://mcp.loogle.it/dashboard
|
||||||
|
|
||||||
|
**Se il browser non si apre:** prova la config con `"CLIENT_ID": "cursor"` sopra, oppure apri manualmente https://mcp.loogle.it/dashboard per testare le credenziali.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collegare Gemini
|
||||||
|
|
||||||
|
**Gemini CLI** (da terminale / PC):
|
||||||
|
|
||||||
|
1. Apri il file di configurazione MCP di Gemini (es. `~/.gemini/settings.json`)
|
||||||
|
2. Aggiungi:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"loogle-mcp": {
|
||||||
|
"url": "https://mcp.loogle.it/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Al primo avvio completa il login OAuth nel browser con le tue credenziali
|
||||||
|
|
||||||
|
L’app Gemini mobile potrebbe non supportare ancora MCP remoto; in quel caso usa ChatGPT o Claude.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard web
|
||||||
|
|
||||||
|
Indirizzo: **https://mcp.loogle.it/dashboard**
|
||||||
|
|
||||||
|
Da qui puoi:
|
||||||
|
|
||||||
|
- Vedere l’elenco dei **tuoi progetti**
|
||||||
|
- **Cambiare password**
|
||||||
|
- Consultare le **ultime azioni** che l’AI ha fatto (audit log)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Come usarlo nel quotidiano
|
||||||
|
|
||||||
|
### 1. Crea un progetto per ogni tema importante
|
||||||
|
|
||||||
|
Chiedi all’AI:
|
||||||
|
|
||||||
|
> «Crea un progetto chiamato "Rinnovo bagno" con tag casa, 2026»
|
||||||
|
|
||||||
|
(Usa il tool `create_project` — l’AI lo farà automaticamente se il connector è attivo.)
|
||||||
|
|
||||||
|
### 2. Lavora e fai salvare la memoria
|
||||||
|
|
||||||
|
A fine sessione:
|
||||||
|
|
||||||
|
> «Salva in save_context un riepilogo di quello che abbiamo deciso oggi, nel progetto rinnovo-bagno»
|
||||||
|
|
||||||
|
La prossima volta l’AI potrà rileggere tutto con `get_project_context`.
|
||||||
|
|
||||||
|
### 3. Cerca documenti di casa
|
||||||
|
|
||||||
|
> «Cerca in search_knowledge la bolletta luce dell’ultimo trimestre»
|
||||||
|
|
||||||
|
> «Trova nel archivio documenti il manuale della caldaia»
|
||||||
|
|
||||||
|
(I documenti devono essere già in Paperless su docs.loogle.it.)
|
||||||
|
|
||||||
|
### 4. Cerca tra i tuoi appunti passati
|
||||||
|
|
||||||
|
> «search_context: cosa avevamo deciso sul parquet?»
|
||||||
|
|
||||||
|
### 5. Codice e runbook su Gitea
|
||||||
|
|
||||||
|
> «list_repos: quali repository ho su git.loogle.it?»
|
||||||
|
|
||||||
|
> «get_file su daniele/rete ha/RUNBOOK-failover.md»
|
||||||
|
|
||||||
|
> «search_code tier-b nel repo daniele/rete»
|
||||||
|
|
||||||
|
### 6. Progetto MCP collegato al codice
|
||||||
|
|
||||||
|
> «create_project "Infra Rete" con gitea_repo daniele/rete e seed_from_gitea true»
|
||||||
|
|
||||||
|
> «link_project_repo sul progetto homelab-loogle → daniele/rete»
|
||||||
|
|
||||||
|
> «get_project_context sul progetto attivo» — include README e elenco file in `docs/` dal repo collegato
|
||||||
|
|
||||||
|
> «search_gitea_knowledge: come funziona il failover tier-b?»
|
||||||
|
|
||||||
|
> «reindex_gitea_repo daniele/rete» — forza aggiornamento indicizzazione
|
||||||
|
|
||||||
|
### 7. Ricerca semantica su runbook Git (RAG)
|
||||||
|
|
||||||
|
> «search_gitea_knowledge failover su daniele/rete»
|
||||||
|
|
||||||
|
> «search_knowledge keepalived VIP» — cerca anche in Paperless e Gitea indicizzati
|
||||||
|
|
||||||
|
L'indexer in background indicizza `.md`, script e config dai repo collegati (vedi `docs/GITEA-TOKEN.md` sezione P3).
|
||||||
|
|
||||||
|
### 8. Casa, rete e Home Assistant (P5)
|
||||||
|
|
||||||
|
> «get_home_weather: che tempo fa a casa?»
|
||||||
|
|
||||||
|
> «get_network_overview: qualcosa è offline in rete?»
|
||||||
|
|
||||||
|
> «get_network_failover_status: com'è lo scenario failover?»
|
||||||
|
|
||||||
|
> «get_ha_entity switch.pompa_pozzo» — stato entità Home Assistant (solo lettura)
|
||||||
|
|
||||||
|
> «search_ha_entities caldaia»
|
||||||
|
|
||||||
|
### 9. Irrigazione e turni (P6)
|
||||||
|
|
||||||
|
> «get_irrigation_status: programma irrigazione oggi»
|
||||||
|
|
||||||
|
> «get_irrigation_zones: quali zone sono attive?»
|
||||||
|
|
||||||
|
> «get_my_shifts: i miei turni di lavoro questo mese»
|
||||||
|
|
||||||
|
> «list_turni_doctors» — elenco medici in Turni-Live
|
||||||
|
|
||||||
|
### 10. Ricerca storica app (P7)
|
||||||
|
|
||||||
|
> «search_apps_knowledge irrigazione prato ieri»
|
||||||
|
|
||||||
|
> «search_knowledge valvola pozzo» — include anche Irrigazione/Turni indicizzati
|
||||||
|
|
||||||
|
Dettagli tecnici e configurazione: `docs/APPS-INTEGRATION.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cosa può e non può fare
|
||||||
|
|
||||||
|
| Può | Non può |
|
||||||
|
|-----|---------|
|
||||||
|
| Leggere i **tuoi** progetti | Vedere progetti di altri familiari |
|
||||||
|
| Cercare documenti **famiglia** e **personali** (secondo tag Paperless) | Modificare Paperless o cancellare PDF |
|
||||||
|
| Aggiungere testo alla **tua** memoria progetto | Accedere senza login OAuth |
|
||||||
|
| Cercare semanticamente (capisce il significato, non solo parole esatte) | Funzionare senza internet verso casa* |
|
||||||
|
| Leggere codice e issue da **Gitea** (repo a cui hai accesso) | Push/commit git via MCP (usa git normalmente) |
|
||||||
|
| Meteo/rete da **Loogle Casa**, sensori **Home Assistant** (lettura) | Controllare luci/valvole via MCP (solo lettura HA) |
|
||||||
|
| Stato **irrigazione** e **turni** (se configurati per te) | Modificare programmi irrigazione o turni via MCP |
|
||||||
|
|
||||||
|
\*Da fuori casa serve VPN WireGuard (`vpn.loogle.it`) o connessione alla rete di casa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problemi comuni
|
||||||
|
|
||||||
|
| Problema | Cosa fare |
|
||||||
|
|----------|-----------|
|
||||||
|
| Login fallisce | Verifica username/password; reset da dashboard |
|
||||||
|
| ChatGPT non trova il server | URL esatto: `https://mcp.loogle.it/mcp` (con https) |
|
||||||
|
| «Knowledge vuota» | I documenti Paperless potrebbero non essere ancora indicizzati — chiedi a Daniele |
|
||||||
|
| Token scaduto | Riconnetti il connector OAuth (disconnect + connect) |
|
||||||
|
| Cursor «Unauthorized» | Clicca **Connect** una volta; login browser; usa config con `"CLIENT_ID": "cursor"` |
|
||||||
|
| Sito non raggiungibile | Sei in VPN/rete casa? Prova https://mcp.loogle.it/health |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contatti
|
||||||
|
|
||||||
|
Per problemi tecnici (token Paperless, servizio down, nuovo progetto di gruppo): **Daniele**.
|
||||||
|
|
||||||
|
Per la password dimenticata: chiedi a Daniele (admin) o usa il dashboard se ricordi quella vecchia.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scheda rapida
|
||||||
|
|
||||||
|
```
|
||||||
|
URL MCP: https://mcp.loogle.it/mcp
|
||||||
|
Dashboard: https://mcp.loogle.it/dashboard
|
||||||
|
Login: il tuo username (lucia / davide / luca)
|
||||||
|
Primo passo: cambia password → collega ChatGPT o Claude → crea un progetto
|
||||||
|
```
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Onboarding client AI — Loogle MCP Hub
|
||||||
|
|
||||||
|
Endpoint MCP: `https://mcp.loogle.it/mcp`
|
||||||
|
|
||||||
|
OAuth: al primo collegamento compare la pagina login Loogle. Usa le credenziali famiglia.
|
||||||
|
|
||||||
|
Password iniziale = username (es. `lucia` / `lucia`). Cambiala da https://mcp.loogle.it/dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Daniele (admin)
|
||||||
|
|
||||||
|
- Username: `daniele`
|
||||||
|
- Tool admin: `reindex_document`, audit completo, revoca token refresh
|
||||||
|
|
||||||
|
## Lucia
|
||||||
|
|
||||||
|
- Username: `lucia`
|
||||||
|
- Progetti isolati in `/mnt/ha-apps/mcp/context/lucia/`
|
||||||
|
|
||||||
|
## Davide
|
||||||
|
|
||||||
|
- Username: `davide`
|
||||||
|
|
||||||
|
## Luca
|
||||||
|
|
||||||
|
- Username: `luca`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ChatGPT (Plus/Pro/Business)
|
||||||
|
|
||||||
|
1. Abilita **Developer Mode** nelle impostazioni ChatGPT
|
||||||
|
2. Settings → Connectors → **Add MCP Connector**
|
||||||
|
3. URL server: `https://mcp.loogle.it/mcp`
|
||||||
|
4. Completa OAuth con le tue credenziali
|
||||||
|
5. Abilita i tool desiderati nella conversazione
|
||||||
|
|
||||||
|
Redirect URI supportati (pre-registrati):
|
||||||
|
- `https://chatgpt.com/connector_platform_oauth_redirect`
|
||||||
|
- `https://chat.openai.com/connector_platform_oauth_redirect`
|
||||||
|
|
||||||
|
## Claude App / Claude Desktop
|
||||||
|
|
||||||
|
1. Settings → Connectors → **Add remote MCP server**
|
||||||
|
2. URL: `https://mcp.loogle.it/mcp`
|
||||||
|
3. Autenticazione OAuth al primo uso
|
||||||
|
|
||||||
|
Redirect URI: `https://claude.ai/api/mcp/auth_callback`
|
||||||
|
|
||||||
|
## Cursor IDE
|
||||||
|
|
||||||
|
Cursor supporta MCP remoto con **OAuth automatico** (Streamable HTTP).
|
||||||
|
|
||||||
|
### Configurazione
|
||||||
|
|
||||||
|
**Globale** (tutti i progetti): `~/.cursor/mcp.json`
|
||||||
|
**Solo questo repo**: `.cursor/mcp.json` (già presente nel progetto loogle-mcp)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"loogle-mcp": {
|
||||||
|
"url": "https://mcp.loogle.it/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Collegamento
|
||||||
|
|
||||||
|
1. Apri **Cursor Settings** → **Tools & MCP**
|
||||||
|
2. Trova **loogle-mcp** → clic **Connect**
|
||||||
|
3. Si apre il browser → login con le **tue** credenziali Loogle (`daniele`, `lucia`, …)
|
||||||
|
4. Autorizza → Cursor torna con i tool attivi
|
||||||
|
|
||||||
|
Redirect OAuth Cursor (già registrati sul server):
|
||||||
|
- `https://www.cursor.com/agents/mcp/oauth/callback` (web / Agents)
|
||||||
|
- `http://localhost:8787/callback` (desktop app)
|
||||||
|
|
||||||
|
### Uso in chat
|
||||||
|
|
||||||
|
Chiedi all'agente di usare i tool MCP, ad esempio:
|
||||||
|
|
||||||
|
> «Usa whoami e list_projects per vedere i miei progetti Loogle»
|
||||||
|
|
||||||
|
Se il server risulta disconnesso: **Reconnect** da Tools & MCP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gemini CLI
|
||||||
|
|
||||||
|
Aggiungi in `~/.gemini/settings.json` (o equivalente):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"loogle": {
|
||||||
|
"url": "https://mcp.loogle.it/mcp",
|
||||||
|
"transport": "http"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Al primo avvio completa il flow OAuth nel browser.
|
||||||
|
|
||||||
|
## Cursor IDE
|
||||||
|
|
||||||
|
File `.cursor/mcp.json` nel progetto:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"loogle-mcp": {
|
||||||
|
"url": "https://mcp.loogle.it/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool disponibili
|
||||||
|
|
||||||
|
| Tool | Descrizione |
|
||||||
|
|------|-------------|
|
||||||
|
| `ping` | Test connettività |
|
||||||
|
| `whoami` | Utente e scope |
|
||||||
|
| `list_projects` | Progetti utente |
|
||||||
|
| `create_project` | Nuovo archivio contesto |
|
||||||
|
| `get_project_context` | Legge memoria progetto |
|
||||||
|
| `save_context` | Salva/appende memoria |
|
||||||
|
| `search_context` | Ricerca semantica contesti |
|
||||||
|
| `search_knowledge` | RAG su Paperless + contesti + Gitea indicizzati |
|
||||||
|
| `search_gitea_knowledge` | RAG solo su file Gitea (runbook, markdown) |
|
||||||
|
| `list_gitea_indexed_files` | File Gitea nel vector store |
|
||||||
|
| `reindex_gitea_repo` | Re-indicizza un repo (admin/owner) |
|
||||||
|
| `get_document` | Testo documento Paperless |
|
||||||
|
| `list_recent_documents` | Ultimi doc indicizzati |
|
||||||
|
| `reindex_document` | Solo admin |
|
||||||
|
| `list_repos` | Repository Gitea accessibili |
|
||||||
|
| `get_file` | Legge file da repo Gitea |
|
||||||
|
| `search_code` | Cerca nel codice sorgente |
|
||||||
|
| `list_issues` / `get_issue` | Issue su repo Gitea |
|
||||||
|
| `create_issue` | Crea issue (scope gitea:write) |
|
||||||
|
| `link_project_repo` | Collega/scollega repo Gitea a un progetto |
|
||||||
|
|
||||||
|
## Esempio prompt agente
|
||||||
|
|
||||||
|
> "Usa list_projects per vedere i miei progetti, poi get_project_context sul progetto attivo e search_knowledge per trovare documenti rilevanti su [argomento]."
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **401 su MCP**: token scaduto — riconnetti il connector OAuth
|
||||||
|
- **Knowledge vuota**: verifica token Paperless e Ollama su DS920
|
||||||
|
- **Dashboard**: https://mcp.loogle.it/dashboard
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Token Paperless per Loogle MCP
|
||||||
|
|
||||||
|
## Cosa NON usare: token «Applicazione social»
|
||||||
|
|
||||||
|
Nell'admin Django di Paperless (`/admin/socialaccount/socialtoken/`) compare il form **«Aggiungi token dell'applicazione social»** con campi Token, Token segreto, Scade il.
|
||||||
|
|
||||||
|
Questi token servono a **django-allauth** per il login OAuth web (Google, Microsoft, ecc.) su Paperless. **Non** sono token dell'API REST di Paperless e **non** vanno usati per Loogle MCP.
|
||||||
|
|
||||||
|
Non devi configurare nulla in quella schermata per il nostro hub MCP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cosa usare: token API utente
|
||||||
|
|
||||||
|
Paperless espone un'API REST autenticata con header:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ogni utente Paperless ha il proprio token, legato ai permessi di quell'account (documenti visibili, tag, ecc.).
|
||||||
|
|
||||||
|
### Dove generarlo
|
||||||
|
|
||||||
|
Per **ogni** familiare con account Paperless (daniele, lucia, davide, luca):
|
||||||
|
|
||||||
|
1. Accedi a https://docs.loogle.it con quell'account
|
||||||
|
2. Menu utente (in alto a destra) → **Profilo** / **Il mio profilo**
|
||||||
|
3. Sezione **Token API** → pulsante freccia circolare (rigenera token)
|
||||||
|
4. Copia il token nella riga corrispondente di `.env`
|
||||||
|
|
||||||
|
Alternativa admin: Django admin → **Token** (app `authtoken`), associato all'utente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configurazione in Loogle MCP
|
||||||
|
|
||||||
|
Modifica `/home/daniely/docker/loogle-mcp/.env`:
|
||||||
|
|
||||||
|
### Opzione consigliata — token per utente (4 familiari)
|
||||||
|
|
||||||
|
```env
|
||||||
|
PAPERLESS_URL=https://docs.loogle.it
|
||||||
|
PAPERLESS_API_TOKEN_DANIELE=
|
||||||
|
PAPERLESS_API_TOKEN_LUCIA=
|
||||||
|
PAPERLESS_API_TOKEN_DAVIDE=
|
||||||
|
PAPERLESS_API_TOKEN_LUCA=
|
||||||
|
```
|
||||||
|
|
||||||
|
| Utente MCP | Variabile `.env` | Account Paperless |
|
||||||
|
|------------|------------------|-------------------|
|
||||||
|
| Daniele | `PAPERLESS_API_TOKEN_DANIELE` | utente admin / superuser |
|
||||||
|
| Lucia | `PAPERLESS_API_TOKEN_LUCIA` | utente Lucia |
|
||||||
|
| Davide | `PAPERLESS_API_TOKEN_DAVIDE` | utente Davide |
|
||||||
|
| Luca | `PAPERLESS_API_TOKEN_LUCA` | utente Luca |
|
||||||
|
|
||||||
|
Righe vuote = utente saltato dall'indexer finché non inserisci il token.
|
||||||
|
|
||||||
|
### Opzione alternativa — un solo token admin
|
||||||
|
|
||||||
|
Se preferisci un unico account Paperless (es. Daniele superuser):
|
||||||
|
|
||||||
|
```env
|
||||||
|
PAPERLESS_API_TOKEN=<token-daniele>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tutti gli utenti MCP useranno quel token in fallback.
|
||||||
|
|
||||||
|
### Opzione C — mappa JSON
|
||||||
|
|
||||||
|
```env
|
||||||
|
PAPERLESS_API_TOKENS={"daniele":"...","lucia":"...","davide":"...","luca":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comportamento nel sistema
|
||||||
|
|
||||||
|
| Componente | Comportamento |
|
||||||
|
|------------|---------------|
|
||||||
|
| **Indexer** | Usa tutti i token configurati, unisce l'elenco documenti (deduplica per ID) |
|
||||||
|
| **`search_knowledge`** | Cerca su tutto ciò che è stato indicizzato |
|
||||||
|
| **`get_document`** | Usa il token dell'utente MCP loggato; se manca, fallback su `PAPERLESS_API_TOKEN` o token Daniele |
|
||||||
|
|
||||||
|
Con token separati, ogni familiare può scaricare via MCP solo i documenti visibili al proprio account Paperless.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Applicare le modifiche
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/daniely/docker/loogle-mcp
|
||||||
|
# modifica .env con i token reali
|
||||||
|
docker compose restart loogle-mcp loogle-mcp-indexer
|
||||||
|
docker logs loogle-mcp-indexer --tail 30
|
||||||
|
```
|
||||||
|
|
||||||
|
Log atteso: indicizzazione da `daniele`, `lucia`, `davide`, `luca` (solo utenti con token impostato).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist onboarding Paperless
|
||||||
|
|
||||||
|
- [ ] Account Paperless creato per daniele, lucia, davide, luca (se non esistono)
|
||||||
|
- [ ] Token API generato per ciascuno (Profilo → Token API)
|
||||||
|
- [ ] Quattro righe compilate in `.env`
|
||||||
|
- [ ] `docker compose restart loogle-mcp loogle-mcp-indexer`
|
||||||
|
- [ ] Log indexer senza errori 401
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.115.12
|
||||||
|
uvicorn[standard]==0.34.2
|
||||||
|
python-multipart==0.0.20
|
||||||
|
PyJWT==2.10.1
|
||||||
|
cryptography==44.0.2
|
||||||
|
httpx==0.28.1
|
||||||
|
qdrant-client==1.13.3
|
||||||
|
requests==2.32.3
|
||||||
|
apscheduler==3.10.4
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
echo "Attendo health..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sf http://127.0.0.1:8700/health >/dev/null; then
|
||||||
|
echo "OK — loogle-mcp attivo su :8700"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Health check fallito — vedi docker logs loogle-mcp" >&2
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
mkdir -p /data /data/context
|
||||||
|
|
||||||
|
# Chiave SSH per thermal gate (permessi OpenSSH)
|
||||||
|
if [ -f /run/secrets/ds920_ssh_key ]; then
|
||||||
|
mkdir -p /root/.ssh
|
||||||
|
cp /run/secrets/ds920_ssh_key /root/.ssh/ds920_key
|
||||||
|
chmod 600 /root/.ssh/ds920_key
|
||||||
|
export DS920_SSH_KEY=/root/.ssh/ds920_key
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Probe temperatura/load CPU DS920 — HTTP GET /thermal su :9191.
|
||||||
|
|
||||||
|
Avvio (sul DS920, utente daniely):
|
||||||
|
nohup python3 ds920_thermal_probe.py >> /tmp/thermal-probe.log 2>&1 &
|
||||||
|
|
||||||
|
O via Task Scheduler Synology all'avvio.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
|
||||||
|
PORT = int(os.environ.get("THERMAL_PROBE_PORT", "9191"))
|
||||||
|
HWMON = os.environ.get("THERMAL_HWMON", "/sys/class/hwmon/hwmon0")
|
||||||
|
|
||||||
|
|
||||||
|
def read_metrics() -> dict:
|
||||||
|
temps = []
|
||||||
|
try:
|
||||||
|
for name in sorted(os.listdir(HWMON)):
|
||||||
|
if name.startswith("temp") and name.endswith("_input"):
|
||||||
|
with open(os.path.join(HWMON, name), encoding="utf-8") as f:
|
||||||
|
temps.append(int(f.read().strip()) / 1000.0)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
load1, load5, load15 = os.getloadavg()
|
||||||
|
nproc = os.cpu_count() or 4
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"host": os.uname().nodename,
|
||||||
|
"cpu_temp_c": max(temps) if temps else None,
|
||||||
|
"temps_c": temps,
|
||||||
|
"load1": load1,
|
||||||
|
"load5": load5,
|
||||||
|
"load15": load15,
|
||||||
|
"nproc": nproc,
|
||||||
|
"cpu_target_load": round(nproc * 0.75, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt: str, *args) -> None: # noqa: A003
|
||||||
|
return
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
if self.path.split("?")[0] not in ("/", "/thermal", "/health"):
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
body = json.dumps(read_metrics()).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
||||||
|
print(f"thermal probe listening on 0.0.0.0:{PORT}", flush=True)
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,721 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Esporta un blocco giornaliero ricco su progetto MCP (default: homelab-loogle).
|
||||||
|
|
||||||
|
Fonti (massimo contesto utile, non dump grezzo):
|
||||||
|
- transcript agenti Cursor (query utente + conclusioni assistente)
|
||||||
|
- audit_log tool MCP del giorno
|
||||||
|
- commit git recenti in repo tipici (rete, loogle-mcp)
|
||||||
|
- piani Cursor `~/.cursor/plans/*.plan.md` → artifacts/plans/ + indice in context.md
|
||||||
|
|
||||||
|
Idempotente: marker <!-- daily-digest:YYYY-MM-DD --> — con --force sostituisce il blocco del giorno.
|
||||||
|
|
||||||
|
Esempi:
|
||||||
|
python3 scripts/export_daily_agent_digest.py
|
||||||
|
python3 scripts/export_daily_agent_digest.py --date 2026-09-03 --force
|
||||||
|
python3 scripts/export_daily_agent_digest.py --dry-run
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
MARKER_RE = re.compile(r"<!--\s*daily-digest:(\d{4}-\d{2}-\d{2})\s*-->")
|
||||||
|
USER_QUERY_RE = re.compile(
|
||||||
|
r"<timestamp>(.*?)</timestamp>\s*<user_query>\s*(.*?)\s*</user_query>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
TS_PREFIX_RE = re.compile(r"^\[?\d{4}-\d{2}-\d{2}")
|
||||||
|
FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
|
||||||
|
|
||||||
|
DEFAULT_TRANSCRIPT_ROOTS = [
|
||||||
|
Path.home() / ".cursor/projects/home-daniely/agent-transcripts",
|
||||||
|
Path.home() / ".cursor/projects/home-daniely-docker-loogle-mcp/agent-transcripts",
|
||||||
|
]
|
||||||
|
DEFAULT_GIT_REPOS = [
|
||||||
|
Path.home() / "rete",
|
||||||
|
Path.home() / "docker/loogle-mcp",
|
||||||
|
]
|
||||||
|
DEFAULT_PLANS_DIR = Path.home() / ".cursor/plans"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv() -> None:
|
||||||
|
env_path = ROOT / ".env"
|
||||||
|
if not env_path.is_file():
|
||||||
|
return
|
||||||
|
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
if key and key not in os.environ:
|
||||||
|
os.environ[key] = value.strip().strip("'").strip('"')
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_paths() -> None:
|
||||||
|
if Path("/.dockerenv").is_file() or (Path("/data").is_dir() and os.access("/data", os.W_OK)):
|
||||||
|
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
|
||||||
|
os.environ.setdefault("MCP_CONTEXT_ROOT", "/data/context")
|
||||||
|
os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
|
||||||
|
return
|
||||||
|
os.environ.setdefault("MCP_DB", str(ROOT / "data" / "loogle_mcp.db"))
|
||||||
|
os.environ.setdefault("MCP_CONTEXT_ROOT", "/mnt/ha-apps/mcp/context")
|
||||||
|
os.environ.setdefault("MCP_VECTOR_FALLBACK", str(ROOT / "data" / "vector_fallback.db"))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_day(s: Optional[str]) -> date:
|
||||||
|
if not s:
|
||||||
|
return date.today()
|
||||||
|
return date.fromisoformat(s)
|
||||||
|
|
||||||
|
|
||||||
|
def _day_bounds(day: date) -> tuple[datetime, datetime]:
|
||||||
|
start = datetime.combine(day, datetime.min.time())
|
||||||
|
end = start + timedelta(days=1)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _is_noise_query(q: str) -> bool:
|
||||||
|
q = (q or "").strip()
|
||||||
|
if len(q) < 12:
|
||||||
|
return True
|
||||||
|
low = q.lower()
|
||||||
|
if q.startswith("<") or q.startswith("[REDACTED]"):
|
||||||
|
return True
|
||||||
|
noise_prefixes = (
|
||||||
|
"you are ",
|
||||||
|
"you have access",
|
||||||
|
"start multitasking",
|
||||||
|
"briefly inform the user",
|
||||||
|
"perform any necessary follow-up",
|
||||||
|
"the following task has finished",
|
||||||
|
"<mcp_",
|
||||||
|
"<dynamic_tools>",
|
||||||
|
"<agent_transcripts>",
|
||||||
|
)
|
||||||
|
return any(low.startswith(p) or p in low[:80] for p in noise_prefixes)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str, limit: int) -> str:
|
||||||
|
text = re.sub(r"\s+", " ", (text or "").strip())
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[: limit - 1].rstrip() + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text_blocks(message: Any) -> list[str]:
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
return []
|
||||||
|
content = message.get("content")
|
||||||
|
if isinstance(content, str):
|
||||||
|
return [content]
|
||||||
|
out: list[str] = []
|
||||||
|
if isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "text":
|
||||||
|
t = part.get("text") or ""
|
||||||
|
if t and t != "[REDACTED]":
|
||||||
|
out.append(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_event_time(raw: str, file_mtime: float) -> Optional[datetime]:
|
||||||
|
raw = (raw or "").strip()
|
||||||
|
cleaned = re.sub(r"\s*\([^)]*\)\s*$", "", raw).strip()
|
||||||
|
for fmt in (
|
||||||
|
"%A, %b %d, %Y, %I:%M %p",
|
||||||
|
"%A, %B %d, %Y, %I:%M %p",
|
||||||
|
"%Y-%m-%dT%H:%M:%S%z",
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(cleaned, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return datetime.fromtimestamp(file_mtime)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_transcripts(day: date, roots: list[Path], max_chats: int = 25) -> list[dict]:
|
||||||
|
start, end = _day_bounds(day)
|
||||||
|
chats: list[dict] = []
|
||||||
|
files: list[Path] = []
|
||||||
|
for root in roots:
|
||||||
|
if not root.is_dir():
|
||||||
|
continue
|
||||||
|
for path in root.rglob("*.jsonl"):
|
||||||
|
if "subagents" in path.parts:
|
||||||
|
continue
|
||||||
|
files.append(path)
|
||||||
|
|
||||||
|
for path in sorted(files, key=lambda p: p.stat().st_mtime, reverse=True):
|
||||||
|
mtime = path.stat().st_mtime
|
||||||
|
# quick skip: file entirely older than day-1 or newer handled by content
|
||||||
|
if datetime.fromtimestamp(mtime) < start - timedelta(days=2):
|
||||||
|
continue
|
||||||
|
user_queries: list[str] = []
|
||||||
|
assistant_tails: list[str] = []
|
||||||
|
paths_touched: Counter[str] = Counter()
|
||||||
|
day_hit = False
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
role = obj.get("role")
|
||||||
|
for text in _extract_text_blocks(obj.get("message") or {}):
|
||||||
|
if role == "user":
|
||||||
|
for ts_raw, query in USER_QUERY_RE.findall(text):
|
||||||
|
when = _parse_event_time(ts_raw, mtime)
|
||||||
|
if when and start <= when < end:
|
||||||
|
day_hit = True
|
||||||
|
q = _truncate(query, 280)
|
||||||
|
if not _is_noise_query(q):
|
||||||
|
user_queries.append(q)
|
||||||
|
# bare user text without wrapper — solo se breve messaggio umano
|
||||||
|
if (
|
||||||
|
"<user_query>" not in text
|
||||||
|
and start.timestamp() <= mtime < end.timestamp()
|
||||||
|
and len(text) > 40
|
||||||
|
and not _is_noise_query(text)
|
||||||
|
and "<" not in text[:20]
|
||||||
|
):
|
||||||
|
day_hit = True
|
||||||
|
user_queries.append(_truncate(text, 280))
|
||||||
|
elif role == "assistant":
|
||||||
|
if start.timestamp() <= mtime < end.timestamp() or day_hit:
|
||||||
|
if (
|
||||||
|
len(text) > 80
|
||||||
|
and not text.startswith("[REDACTED]")
|
||||||
|
and not text.startswith("<")
|
||||||
|
and "tool_use" not in text[:40]
|
||||||
|
):
|
||||||
|
# preferisci paragrafi conclusivi (markdown grassetto / verdetto)
|
||||||
|
assistant_tails.append(_truncate(text, 320))
|
||||||
|
# tool paths
|
||||||
|
msg = obj.get("message") or {}
|
||||||
|
content = msg.get("content") if isinstance(msg, dict) else None
|
||||||
|
if isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if not isinstance(part, dict) or part.get("type") != "tool_use":
|
||||||
|
continue
|
||||||
|
inp = part.get("input") or {}
|
||||||
|
for key in ("path", "target_notebook", "file_path"):
|
||||||
|
if key in inp and isinstance(inp[key], str):
|
||||||
|
paths_touched[inp[key]] += 1
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not day_hit and not user_queries:
|
||||||
|
# include if file modified that day and has substance
|
||||||
|
if not (start.timestamp() <= mtime < end.timestamp()):
|
||||||
|
continue
|
||||||
|
if not assistant_tails:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# dedupe queries
|
||||||
|
seen = set()
|
||||||
|
uniq_q = []
|
||||||
|
for q in user_queries:
|
||||||
|
if q not in seen:
|
||||||
|
seen.add(q)
|
||||||
|
uniq_q.append(q)
|
||||||
|
chats.append(
|
||||||
|
{
|
||||||
|
"id": path.parent.name if path.parent.name != "agent-transcripts" else path.stem,
|
||||||
|
"queries": uniq_q[:8],
|
||||||
|
"conclusions": assistant_tails[-3:],
|
||||||
|
"paths": [p for p, _ in paths_touched.most_common(8)],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(chats) >= max_chats:
|
||||||
|
break
|
||||||
|
return chats
|
||||||
|
|
||||||
|
|
||||||
|
def collect_audit(day: date, db_path: Path) -> dict:
|
||||||
|
if not db_path.is_file():
|
||||||
|
return {"tools": [], "total": 0}
|
||||||
|
start = day.isoformat()
|
||||||
|
end = (day + timedelta(days=1)).isoformat()
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT tool_name, COUNT(*) AS n FROM audit_log"
|
||||||
|
" WHERE created_at >= ? AND created_at < ? AND username=?"
|
||||||
|
" GROUP BY tool_name ORDER BY n DESC",
|
||||||
|
(start, end, "daniele"),
|
||||||
|
).fetchall()
|
||||||
|
samples = conn.execute(
|
||||||
|
"SELECT created_at, tool_name, detail FROM audit_log"
|
||||||
|
" WHERE created_at >= ? AND created_at < ? AND username=?"
|
||||||
|
" ORDER BY id DESC LIMIT 15",
|
||||||
|
(start, end, "daniele"),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return {
|
||||||
|
"tools": [(r["tool_name"], r["n"]) for r in rows],
|
||||||
|
"total": sum(r["n"] for r in rows),
|
||||||
|
"samples": [
|
||||||
|
{
|
||||||
|
"at": r["created_at"],
|
||||||
|
"tool": r["tool_name"],
|
||||||
|
"detail": _truncate(r["detail"] or "", 120),
|
||||||
|
}
|
||||||
|
for r in samples
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_git(day: date, repos: list[Path], limit: int = 12) -> list[str]:
|
||||||
|
since = day.isoformat()
|
||||||
|
until = (day + timedelta(days=1)).isoformat()
|
||||||
|
lines: list[str] = []
|
||||||
|
for repo in repos:
|
||||||
|
if not (repo / ".git").exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
str(repo),
|
||||||
|
"log",
|
||||||
|
f"--since={since}",
|
||||||
|
f"--until={until}",
|
||||||
|
"--pretty=format:%h %s",
|
||||||
|
f"-n{limit}",
|
||||||
|
],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
).strip()
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||||
|
continue
|
||||||
|
if out:
|
||||||
|
for line in out.splitlines():
|
||||||
|
lines.append(f"{repo.name}: {line}")
|
||||||
|
return lines[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_plan_file(path: Path) -> dict:
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
name = path.stem
|
||||||
|
overview = ""
|
||||||
|
todos_total = 0
|
||||||
|
todos_done = 0
|
||||||
|
m = FRONTMATTER_RE.match(text)
|
||||||
|
if m:
|
||||||
|
fm = m.group(1)
|
||||||
|
nm = re.search(r"^name:\s*(.+)$", fm, re.M)
|
||||||
|
if nm:
|
||||||
|
name = nm.group(1).strip().strip("\"'")
|
||||||
|
ov = re.search(r"^overview:\s*(.+)$", fm, re.M)
|
||||||
|
if ov:
|
||||||
|
overview = ov.group(1).strip().strip("\"'")
|
||||||
|
# overview può essere su più righe YAML quoted — fallback grezzo
|
||||||
|
if overview.startswith("|") or not overview:
|
||||||
|
ov2 = re.search(r"^overview:\s*[>|]?\s*\n((?:[ \t]+.+\n)+)", fm, re.M)
|
||||||
|
if ov2:
|
||||||
|
overview = " ".join(line.strip() for line in ov2.group(1).splitlines())
|
||||||
|
statuses = re.findall(r"^\s+status:\s*(\w+)", fm, re.M)
|
||||||
|
todos_total = len(statuses)
|
||||||
|
todos_done = sum(1 for s in statuses if s == "completed")
|
||||||
|
mtime = datetime.fromtimestamp(path.stat().st_mtime)
|
||||||
|
return {
|
||||||
|
"file": path.name,
|
||||||
|
"name": name,
|
||||||
|
"overview": _truncate(overview, 220),
|
||||||
|
"todos_total": todos_total,
|
||||||
|
"todos_done": todos_done,
|
||||||
|
"mtime": mtime,
|
||||||
|
"text": text,
|
||||||
|
"path": path,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_and_sync_plans(
|
||||||
|
day: date,
|
||||||
|
plans_dir: Path,
|
||||||
|
dest_dir: Path,
|
||||||
|
*,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Copia tutti i .plan.md in artifacts/plans/; ritorna catalogo + aggiornati nel giorno."""
|
||||||
|
start, end = _day_bounds(day)
|
||||||
|
plans: list[dict] = []
|
||||||
|
if plans_dir.is_dir():
|
||||||
|
for path in sorted(plans_dir.glob("*.plan.md")):
|
||||||
|
try:
|
||||||
|
plans.append(_parse_plan_file(path))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
copied = 0
|
||||||
|
updated_today: list[dict] = []
|
||||||
|
if not dry_run:
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for plan in plans:
|
||||||
|
target = dest_dir / plan["file"]
|
||||||
|
shutil.copy2(plan["path"], target)
|
||||||
|
copied += 1
|
||||||
|
if start <= plan["mtime"] < end:
|
||||||
|
updated_today.append(plan)
|
||||||
|
# INDEX.md per lettura umana / futuri tool
|
||||||
|
idx_lines = [
|
||||||
|
"# Piani Cursor (sync)",
|
||||||
|
"",
|
||||||
|
f"_Aggiornato {datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')} "
|
||||||
|
f"da `~/.cursor/plans` → `artifacts/plans/`._",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for plan in sorted(plans, key=lambda p: p["mtime"], reverse=True):
|
||||||
|
prog = (
|
||||||
|
f"{plan['todos_done']}/{plan['todos_total']}"
|
||||||
|
if plan["todos_total"]
|
||||||
|
else "?"
|
||||||
|
)
|
||||||
|
idx_lines.append(
|
||||||
|
f"- **{plan['name']}** (`{plan['file']}`, todos {prog}, "
|
||||||
|
f"mtime {plan['mtime'].date().isoformat()})"
|
||||||
|
)
|
||||||
|
if plan["overview"]:
|
||||||
|
idx_lines.append(f" - {plan['overview']}")
|
||||||
|
idx_lines.append("")
|
||||||
|
(dest_dir / "INDEX.md").write_text("\n".join(idx_lines), encoding="utf-8")
|
||||||
|
else:
|
||||||
|
updated_today = [p for p in plans if start <= p["mtime"] < end]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"plans": plans,
|
||||||
|
"copied": copied,
|
||||||
|
"updated_today": updated_today,
|
||||||
|
"dest": str(dest_dir),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_plans_index_section(context_md: str, plans: list[dict]) -> str:
|
||||||
|
"""Sezione stabile in context.md (non nel digest giornaliero) con indice piani."""
|
||||||
|
begin = "<!-- BEGIN CURSOR PLANS -->"
|
||||||
|
end = "<!-- END CURSOR PLANS -->"
|
||||||
|
lines = [
|
||||||
|
begin,
|
||||||
|
"## Piani Cursor (indice sync)",
|
||||||
|
"",
|
||||||
|
"_File completi in `artifacts/plans/`. Qui solo indice per `get_project_context`._",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
if not plans:
|
||||||
|
lines.append("- Nessun piano in `~/.cursor/plans`.")
|
||||||
|
else:
|
||||||
|
for plan in sorted(plans, key=lambda p: p["mtime"], reverse=True):
|
||||||
|
prog = (
|
||||||
|
f"{plan['todos_done']}/{plan['todos_total']} done"
|
||||||
|
if plan["todos_total"]
|
||||||
|
else "n/d"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f"- **{plan['name']}** — {prog} — `{plan['file']}` "
|
||||||
|
f"({plan['mtime'].date().isoformat()})"
|
||||||
|
)
|
||||||
|
if plan["overview"]:
|
||||||
|
lines.append(f" - {plan['overview']}")
|
||||||
|
lines.extend(["", end, ""])
|
||||||
|
block = "\n".join(lines)
|
||||||
|
|
||||||
|
if begin in context_md and end in context_md:
|
||||||
|
pattern = re.compile(
|
||||||
|
re.escape(begin) + r".*?" + re.escape(end),
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
return pattern.sub(block.strip(), context_md)
|
||||||
|
# inserisci prima dei daily digests se presenti
|
||||||
|
dig = "<!-- BEGIN DAILY DIGESTS -->"
|
||||||
|
if dig in context_md:
|
||||||
|
head, tail = context_md.split(dig, 1)
|
||||||
|
return head.rstrip() + "\n\n" + block + "\n" + dig + tail
|
||||||
|
return context_md.rstrip() + "\n\n" + block
|
||||||
|
|
||||||
|
|
||||||
|
def build_markdown(
|
||||||
|
day: date,
|
||||||
|
chats: list[dict],
|
||||||
|
audit: dict,
|
||||||
|
git_lines: list[str],
|
||||||
|
project_id: str,
|
||||||
|
plans_updated: Optional[list[dict]] = None,
|
||||||
|
) -> str:
|
||||||
|
lines: list[str] = [
|
||||||
|
f"<!-- daily-digest:{day.isoformat()} -->",
|
||||||
|
f"## Digest agenti {day.isoformat()} — `{project_id}`",
|
||||||
|
"",
|
||||||
|
f"_Generato automaticamente da `export_daily_agent_digest.py` "
|
||||||
|
f"({datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')})._",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
lines.append("### Chat / agenti Cursor")
|
||||||
|
if not chats:
|
||||||
|
lines.append("- Nessun transcript rilevante per questo giorno.")
|
||||||
|
else:
|
||||||
|
lines.append(f"- Sessioni considerate: **{len(chats)}**")
|
||||||
|
for i, chat in enumerate(chats, 1):
|
||||||
|
title = chat["queries"][0] if chat["queries"] else chat["id"]
|
||||||
|
lines.append(f"{i}. **{_truncate(title, 120)}** `[{chat['id'][:8]}]`")
|
||||||
|
for q in chat["queries"][1:4]:
|
||||||
|
lines.append(f" - Q: {_truncate(q, 160)}")
|
||||||
|
for c in chat["conclusions"][-2:]:
|
||||||
|
lines.append(f" - → {_truncate(c, 200)}")
|
||||||
|
if chat["paths"]:
|
||||||
|
short_paths = ", ".join(_truncate(p, 60) for p in chat["paths"][:5])
|
||||||
|
lines.append(f" - File: `{short_paths}`")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("### Tool MCP usati")
|
||||||
|
if not audit.get("total"):
|
||||||
|
lines.append("- Nessuna chiamata tool in audit_log.")
|
||||||
|
else:
|
||||||
|
lines.append(f"- Totale chiamate: **{audit['total']}**")
|
||||||
|
top = ", ".join(f"`{name}`×{n}" for name, n in audit["tools"][:10])
|
||||||
|
lines.append(f"- Top: {top}")
|
||||||
|
for s in (audit.get("samples") or [])[:8]:
|
||||||
|
lines.append(f" - {s['at']} `{s['tool']}` {s['detail']}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("### Commit git (homelab)")
|
||||||
|
if not git_lines:
|
||||||
|
lines.append("- Nessun commit nel giorno.")
|
||||||
|
else:
|
||||||
|
for g in git_lines:
|
||||||
|
lines.append(f"- `{g}`")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("### Piani Cursor aggiornati oggi")
|
||||||
|
plans_updated = plans_updated or []
|
||||||
|
if not plans_updated:
|
||||||
|
lines.append("- Nessun `.plan.md` modificato in questa data.")
|
||||||
|
else:
|
||||||
|
for plan in plans_updated:
|
||||||
|
prog = (
|
||||||
|
f"{plan['todos_done']}/{plan['todos_total']}"
|
||||||
|
if plan["todos_total"]
|
||||||
|
else "?"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f"- **{plan['name']}** (`artifacts/plans/{plan['file']}`, todos {prog})"
|
||||||
|
)
|
||||||
|
if plan["overview"]:
|
||||||
|
lines.append(f" - {plan['overview']}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("### Per Claude / prossimi agenti")
|
||||||
|
lines.append(
|
||||||
|
"- Usa questo blocco come memoria del giorno; per dettagli codice preferisci "
|
||||||
|
"`search_gitea_knowledge` / `get_file` sui path citati."
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
"- Piani completi: sezione **Piani Cursor** in context.md + file in `artifacts/plans/`."
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
"- Non ripetere setup già conclusi; riparti da decisioni e next step qui sopra."
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_daily_block(context_md: str, day: date, block: str) -> str:
|
||||||
|
"""Rimuove eventuale blocco del giorno e inserisce il nuovo in cima alla sezione digest."""
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"<!--\s*daily-digest:{day.isoformat()}\s*-->.*?"
|
||||||
|
rf"(?=<!--\s*daily-digest:\d{{4}}-\d{{2}}-\d{{2}}\s*-->|<!--\s*END DAILY DIGESTS\s*-->|\Z)",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
context_md = pattern.sub("", context_md)
|
||||||
|
|
||||||
|
begin = "<!-- BEGIN DAILY DIGESTS -->"
|
||||||
|
end = "<!-- END DAILY DIGESTS -->"
|
||||||
|
if begin not in context_md:
|
||||||
|
context_md = context_md.rstrip() + f"\n\n{begin}\n\n{end}\n"
|
||||||
|
|
||||||
|
# Assicura END
|
||||||
|
if end not in context_md:
|
||||||
|
context_md = context_md.rstrip() + f"\n\n{end}\n"
|
||||||
|
|
||||||
|
head, rest = context_md.split(begin, 1)
|
||||||
|
# rest inizia dopo BEGIN; togli END temporaneamente dalla porzione digest
|
||||||
|
if end in rest:
|
||||||
|
mid, tail = rest.split(end, 1)
|
||||||
|
else:
|
||||||
|
mid, tail = rest, ""
|
||||||
|
mid = mid.strip()
|
||||||
|
new_mid = block.strip() + ("\n\n" + mid if mid else "")
|
||||||
|
return head.rstrip() + f"\n\n{begin}\n\n{new_mid}\n\n{end}" + tail
|
||||||
|
|
||||||
|
|
||||||
|
def has_daily_block(context_md: str, day: date) -> bool:
|
||||||
|
return f"<!-- daily-digest:{day.isoformat()} -->" in context_md
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Digest giornaliero agenti → MCP context")
|
||||||
|
parser.add_argument("--date", help="YYYY-MM-DD (default: oggi)")
|
||||||
|
parser.add_argument("--user", default="daniele")
|
||||||
|
parser.add_argument("--project", default="homelab-loogle")
|
||||||
|
parser.add_argument("--force", action="store_true", help="Sostituisci blocco del giorno se già presente")
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument("--no-index", action="store_true", help="Non aggiornare Qdrant ctx_*")
|
||||||
|
parser.add_argument("--max-chats", type=int, default=20)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-plans",
|
||||||
|
action="store_true",
|
||||||
|
help="Non sincronizzare ~/.cursor/plans",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
_load_dotenv()
|
||||||
|
_configure_paths()
|
||||||
|
day = _parse_day(args.date)
|
||||||
|
|
||||||
|
proj = Path(os.environ["MCP_CONTEXT_ROOT"]) / args.user / "projects" / args.project
|
||||||
|
plans_dest = proj / "artifacts" / "plans"
|
||||||
|
|
||||||
|
plans_info: dict = {"plans": [], "copied": 0, "updated_today": [], "dest": str(plans_dest)}
|
||||||
|
if not args.no_plans:
|
||||||
|
plans_info = collect_and_sync_plans(
|
||||||
|
day,
|
||||||
|
DEFAULT_PLANS_DIR,
|
||||||
|
plans_dest,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
chats = collect_transcripts(day, DEFAULT_TRANSCRIPT_ROOTS, max_chats=args.max_chats)
|
||||||
|
audit = collect_audit(day, Path(os.environ["MCP_DB"]))
|
||||||
|
git_lines = collect_git(day, DEFAULT_GIT_REPOS)
|
||||||
|
block = build_markdown(
|
||||||
|
day,
|
||||||
|
chats,
|
||||||
|
audit,
|
||||||
|
git_lines,
|
||||||
|
args.project,
|
||||||
|
plans_updated=plans_info.get("updated_today") or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(block)
|
||||||
|
if plans_info.get("plans"):
|
||||||
|
print("\n# plans index preview", file=sys.stderr)
|
||||||
|
for p in plans_info["plans"][:5]:
|
||||||
|
print(f"# - {p['name']} ({p['file']})", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
f"\n# dry-run chats={len(chats)} audit={audit.get('total', 0)} "
|
||||||
|
f"git={len(git_lines)} plans={len(plans_info.get('plans') or [])} "
|
||||||
|
f"plans_today={len(plans_info.get('updated_today') or [])} chars={len(block)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
from app.context import store as context_store
|
||||||
|
from app.db import init_db
|
||||||
|
from app.knowledge import indexer
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
data = context_store.get_project_context(args.user, args.project, session_limit=1, include_gitea=False)
|
||||||
|
existing = data.get("context_md") or ""
|
||||||
|
|
||||||
|
# I piani si sincronizzano sempre; il digest può essere skippato se già presente
|
||||||
|
existing = upsert_plans_index_section(existing, plans_info.get("plans") or [])
|
||||||
|
|
||||||
|
digest_skipped = False
|
||||||
|
if has_daily_block(existing, day) and not args.force:
|
||||||
|
digest_skipped = True
|
||||||
|
new_md = existing
|
||||||
|
print(f"SKIP digest: {day.isoformat()} già presente (usa --force); plans sync ok")
|
||||||
|
else:
|
||||||
|
new_md = upsert_daily_block(existing, day, block)
|
||||||
|
|
||||||
|
ctx_path = proj / "context.md"
|
||||||
|
meta_path = proj / "meta.json"
|
||||||
|
sessions = proj / "sessions"
|
||||||
|
sessions.mkdir(parents=True, exist_ok=True)
|
||||||
|
ctx_path.write_text(new_md, encoding="utf-8")
|
||||||
|
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||||
|
meta["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
snap = None
|
||||||
|
if not digest_skipped:
|
||||||
|
snap = sessions / f"{day.isoformat()}-daily.md"
|
||||||
|
snap.write_text(block, encoding="utf-8")
|
||||||
|
|
||||||
|
if not args.no_index:
|
||||||
|
try:
|
||||||
|
if not digest_skipped:
|
||||||
|
indexer.index_context_snippet(
|
||||||
|
args.user,
|
||||||
|
args.project,
|
||||||
|
block,
|
||||||
|
f"Digest {day.isoformat()} — {meta.get('title', args.project)}",
|
||||||
|
snippet_id=f"daily-{day.isoformat()}",
|
||||||
|
)
|
||||||
|
# indicizza ogni piano (testo ridotto: name+overview+body troncato)
|
||||||
|
for plan in plans_info.get("plans") or []:
|
||||||
|
body = plan["text"]
|
||||||
|
if len(body) > 12000:
|
||||||
|
body = body[:12000] + "\n…[troncato]"
|
||||||
|
indexer.index_context_snippet(
|
||||||
|
args.user,
|
||||||
|
args.project,
|
||||||
|
f"# Piano Cursor: {plan['name']}\n\n{plan['overview']}\n\n{body}",
|
||||||
|
f"Plan: {plan['name']}",
|
||||||
|
snippet_id=f"plan-{plan['file']}",
|
||||||
|
)
|
||||||
|
print("indexed: ok")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"indexed: skip ({exc})")
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"day": day.isoformat(),
|
||||||
|
"project": args.project,
|
||||||
|
"digest_skipped": digest_skipped,
|
||||||
|
"chats": len(chats),
|
||||||
|
"audit_calls": audit.get("total", 0),
|
||||||
|
"git_commits": len(git_lines),
|
||||||
|
"plans_synced": plans_info.get("copied", 0),
|
||||||
|
"plans_updated_today": len(plans_info.get("updated_today") or []),
|
||||||
|
"chars": len(block),
|
||||||
|
"context_chars": len(new_md),
|
||||||
|
"session": str(snap) if snap else None,
|
||||||
|
"plans_dest": plans_info.get("dest"),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Grant view permissions on non-personal Paperless documents to family users."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
PAPERLESS_URL = os.environ.get("PAPERLESS_URL", "https://docs.loogle.it").rstrip("/")
|
||||||
|
ADMIN_TOKEN = os.environ.get("PAPERLESS_API_TOKEN_DANIELE") or os.environ.get("PAPERLESS_API_TOKEN", "")
|
||||||
|
FAMILY_USER_IDS = [4, 5, 6] # lucia, davide, luca
|
||||||
|
PERSONAL_TAG_NAMES = {"personal", "privato", "private"}
|
||||||
|
BATCH_SIZE = 50
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not ADMIN_TOKEN:
|
||||||
|
print("Missing PAPERLESS_API_TOKEN_DANIELE", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
headers = {"Authorization": f"Token {ADMIN_TOKEN}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
tags_resp = httpx.get(f"{PAPERLESS_URL}/api/tags/", headers=headers, timeout=60)
|
||||||
|
tags_resp.raise_for_status()
|
||||||
|
personal_tag_ids = {
|
||||||
|
t["id"]
|
||||||
|
for t in tags_resp.json().get("results", [])
|
||||||
|
if (t.get("name") or "").lower() in PERSONAL_TAG_NAMES
|
||||||
|
}
|
||||||
|
|
||||||
|
doc_ids: list[int] = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
resp = httpx.get(
|
||||||
|
f"{PAPERLESS_URL}/api/documents/",
|
||||||
|
headers=headers,
|
||||||
|
params={"page": page, "page_size": 100},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
for doc in data.get("results", []):
|
||||||
|
if set(doc.get("tags") or []) & personal_tag_ids:
|
||||||
|
continue
|
||||||
|
doc_ids.append(doc["id"])
|
||||||
|
if not data.get("next"):
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
print(f"Documenti da condividere (senza tag personali): {len(doc_ids)}")
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for i in range(0, len(doc_ids), BATCH_SIZE):
|
||||||
|
chunk = doc_ids[i : i + BATCH_SIZE]
|
||||||
|
payload = {
|
||||||
|
"documents": chunk,
|
||||||
|
"method": "set_permissions",
|
||||||
|
"parameters": {
|
||||||
|
"set_permissions": {
|
||||||
|
"view": {"users": FAMILY_USER_IDS, "groups": []},
|
||||||
|
"change": {"users": [], "groups": []},
|
||||||
|
},
|
||||||
|
"merge": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp = httpx.post(
|
||||||
|
f"{PAPERLESS_URL}/api/documents/bulk_edit/",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
updated += len(chunk)
|
||||||
|
print(f" Aggiornati {updated}/{len(doc_ids)}")
|
||||||
|
|
||||||
|
for name, env_key in [
|
||||||
|
("lucia", "PAPERLESS_API_TOKEN_LUCIA"),
|
||||||
|
("davide", "PAPERLESS_API_TOKEN_DAVIDE"),
|
||||||
|
("luca", "PAPERLESS_API_TOKEN_LUCA"),
|
||||||
|
]:
|
||||||
|
token = os.environ.get(env_key, "")
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
count = httpx.get(
|
||||||
|
f"{PAPERLESS_URL}/api/documents/?page_size=1",
|
||||||
|
headers={"Authorization": f"Token {token}"},
|
||||||
|
timeout=30,
|
||||||
|
).json().get("count", 0)
|
||||||
|
print(f"{name}: documenti visibili = {count}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Indicizzazione Gitea one-shot — eseguire con indexer fermo per evitare OOM."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv() -> None:
|
||||||
|
env_path = os.path.join(ROOT, ".env")
|
||||||
|
if not os.path.isfile(env_path):
|
||||||
|
return
|
||||||
|
with open(env_path, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
if key.strip() and key.strip() not in os.environ:
|
||||||
|
os.environ[key.strip()] = value.strip().strip("'").strip('"')
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Indicizza repo Gitea nel vector store")
|
||||||
|
parser.add_argument("--repo", help="owner/name (default: tutti i repo configurati)")
|
||||||
|
parser.add_argument("--user", default="daniele", help="Utente MCP/Gitea token")
|
||||||
|
parser.add_argument("--max-files", type=int, default=0, help="Limite file per repo (0=config)")
|
||||||
|
parser.add_argument("--force", action="store_true", help="Re-indicizza anche file invariati")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
_load_dotenv()
|
||||||
|
if os.path.isfile("/.dockerenv"):
|
||||||
|
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
|
||||||
|
os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
|
||||||
|
|
||||||
|
from app.db import init_db
|
||||||
|
from app.knowledge import gitea_indexer
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
max_files = args.max_files or None
|
||||||
|
|
||||||
|
if args.repo:
|
||||||
|
result = gitea_indexer.index_repo(
|
||||||
|
args.repo,
|
||||||
|
username=args.user,
|
||||||
|
force=args.force,
|
||||||
|
max_files=max_files,
|
||||||
|
)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
result = gitea_indexer.index_all(max_files_per_repo=max_files)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
stats = gitea_indexer.index_stats()
|
||||||
|
print("stats", json.dumps(stats, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Crea un progetto demo per ogni utente famiglia."""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "/srv")
|
||||||
|
from app.context import store as context_store
|
||||||
|
|
||||||
|
USERS = {
|
||||||
|
"daniele": "Homelab LOOGLE",
|
||||||
|
"lucia": "Progetti personali",
|
||||||
|
"davide": "Studio e task",
|
||||||
|
"luca": "Progetti Luca",
|
||||||
|
}
|
||||||
|
|
||||||
|
for username, title in USERS.items():
|
||||||
|
root = context_store._user_root(username)
|
||||||
|
existing = context_store.list_projects(username)
|
||||||
|
if existing:
|
||||||
|
print(f"{username}: skip ({len(existing)} progetti)")
|
||||||
|
continue
|
||||||
|
meta = context_store.create_project(username, title, tags=["demo"])
|
||||||
|
context_store.save_context(
|
||||||
|
username,
|
||||||
|
meta["id"],
|
||||||
|
f"Progetto demo iniziale per {username}. Usa save_context per ampliare la memoria agente.",
|
||||||
|
)
|
||||||
|
print(f"{username}: creato {meta['id']}")
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Crea repo progetti per davide/luca e progetti Loogle collegati."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv() -> None:
|
||||||
|
path = os.path.join(ROOT, ".env")
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, _, v = line.partition("=")
|
||||||
|
os.environ[k.strip()] = v.strip().strip("'").strip('"')
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_repo(user: str, repo_name: str, description: str) -> str:
|
||||||
|
from app.knowledge import gitea
|
||||||
|
|
||||||
|
gitea._tokens_cache = None
|
||||||
|
full = f"{user}/{repo_name}"
|
||||||
|
existing = {r["full_name"] for r in gitea.list_repos(username=user)["repos"]}
|
||||||
|
if full in existing:
|
||||||
|
print(f"repo_exists: {full}")
|
||||||
|
return full
|
||||||
|
created = gitea.create_repo(
|
||||||
|
repo_name,
|
||||||
|
username=user,
|
||||||
|
private=True,
|
||||||
|
description=description,
|
||||||
|
auto_init=True,
|
||||||
|
)
|
||||||
|
print(f"repo_created: {created['full_name']}")
|
||||||
|
return created["full_name"]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_mcp_project(user: str, project_title: str, gitea_repo: str) -> None:
|
||||||
|
from app.context import store as context_store
|
||||||
|
|
||||||
|
projects = {p["id"]: p for p in context_store.list_projects(user, include_archived=True)}
|
||||||
|
for meta in projects.values():
|
||||||
|
if meta.get("gitea_repo") == gitea_repo:
|
||||||
|
print(f"project_linked: {user}/{meta['id']} -> {gitea_repo}")
|
||||||
|
return
|
||||||
|
slug = project_title.lower().replace(" ", "-")
|
||||||
|
if slug in projects:
|
||||||
|
meta = context_store.link_project_repo(user, slug, gitea_repo=gitea_repo, seed_from_gitea=True)
|
||||||
|
print(f"project_updated: {user}/{meta['id']} -> {gitea_repo}")
|
||||||
|
return
|
||||||
|
meta = context_store.create_project(
|
||||||
|
user,
|
||||||
|
project_title,
|
||||||
|
tags=["gitea", "p4"],
|
||||||
|
gitea_repo=gitea_repo,
|
||||||
|
seed_from_gitea=True,
|
||||||
|
)
|
||||||
|
print(f"project_created: {user}/{meta['id']} -> {gitea_repo}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
_load_dotenv()
|
||||||
|
os.environ.setdefault("MCP_DB", os.environ.get("MCP_DB", "/data/loogle_mcp.db"))
|
||||||
|
from app.db import init_db
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
ensure_repo(
|
||||||
|
"davide",
|
||||||
|
"progetti",
|
||||||
|
"Workspace personale — codice e note generate con Loogle MCP",
|
||||||
|
)
|
||||||
|
ensure_repo(
|
||||||
|
"luca",
|
||||||
|
"progetti",
|
||||||
|
"Workspace personale — codice e note generate con Loogle MCP",
|
||||||
|
)
|
||||||
|
|
||||||
|
ensure_mcp_project("davide", "Progetti Davide", "davide/progetti")
|
||||||
|
ensure_mcp_project("luca", "Progetti Luca", "luca/progetti")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Test integrazione Gitea MCP — mock server + opzionale live API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
MOCK_TOKEN = "test-gitea-token"
|
||||||
|
MOCK_PORT = 18799
|
||||||
|
|
||||||
|
os.environ["GITEA_URL"] = "https://git.loogle.it"
|
||||||
|
os.environ["GITEA_API_URL"] = f"http://127.0.0.1:{MOCK_PORT}"
|
||||||
|
os.environ["GITEA_API_TOKEN_DANIELE"] = MOCK_TOKEN
|
||||||
|
os.environ["MCP_DB"] = "/tmp/loogle_mcp_test.db"
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaMockHandler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
return
|
||||||
|
|
||||||
|
def _auth_ok(self) -> bool:
|
||||||
|
auth = self.headers.get("Authorization", "")
|
||||||
|
return auth == f"token {MOCK_TOKEN}"
|
||||||
|
|
||||||
|
def _json(self, code: int, payload):
|
||||||
|
body = json.dumps(payload).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if not self._auth_ok():
|
||||||
|
self._json(401, {"message": "invalid token"})
|
||||||
|
return
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
qs = parse_qs(urlparse(self.path).query)
|
||||||
|
|
||||||
|
if path == "/api/v1/user/repos":
|
||||||
|
self._json(200, [
|
||||||
|
{
|
||||||
|
"full_name": "daniele/rete",
|
||||||
|
"description": "Infra HA",
|
||||||
|
"private": True,
|
||||||
|
"html_url": "https://git.loogle.it/daniele/rete",
|
||||||
|
"default_branch": "main",
|
||||||
|
"updated_at": "2026-08-22T10:00:00Z",
|
||||||
|
}
|
||||||
|
])
|
||||||
|
return
|
||||||
|
|
||||||
|
if path == "/api/v1/repos/daniele/rete/contents/ha/RUNBOOK-failover.md":
|
||||||
|
content = "# Failover tier-b\n\nloogle-mcp nel pivot tier-b.\n"
|
||||||
|
import base64
|
||||||
|
|
||||||
|
self._json(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"path": "ha/RUNBOOK-failover.md",
|
||||||
|
"content": base64.b64encode(content.encode()).decode(),
|
||||||
|
"encoding": "base64",
|
||||||
|
"size": len(content),
|
||||||
|
"sha": "abc123",
|
||||||
|
"html_url": "https://git.loogle.it/daniele/rete/src/branch/main/ha/RUNBOOK-failover.md",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if path == "/api/v1/search/code":
|
||||||
|
q = (qs.get("q") or [""])[0]
|
||||||
|
self._json(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"repository": {"full_name": "daniele/rete"},
|
||||||
|
"path": "ha/RUNBOOK-failover.md",
|
||||||
|
"sha": "abc123",
|
||||||
|
"content": f"...{q}...",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if path == "/api/v1/repos/daniele/rete/issues":
|
||||||
|
self._json(
|
||||||
|
200,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"number": 1,
|
||||||
|
"title": "Test issue",
|
||||||
|
"state": "open",
|
||||||
|
"user": {"login": "daniele"},
|
||||||
|
"html_url": "https://git.loogle.it/daniele/rete/issues/1",
|
||||||
|
"created_at": "2026-08-22T10:00:00Z",
|
||||||
|
"updated_at": "2026-08-22T10:00:00Z",
|
||||||
|
"labels": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if path == "/api/v1/repos/daniele/rete/issues/1":
|
||||||
|
self._json(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"number": 1,
|
||||||
|
"title": "Test issue",
|
||||||
|
"state": "open",
|
||||||
|
"body": "Corpo issue di test",
|
||||||
|
"user": {"login": "daniele"},
|
||||||
|
"html_url": "https://git.loogle.it/daniele/rete/issues/1",
|
||||||
|
"created_at": "2026-08-22T10:00:00Z",
|
||||||
|
"updated_at": "2026-08-22T10:00:00Z",
|
||||||
|
"labels": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._json(404, {"message": f"not found: {path}"})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if not self._auth_ok():
|
||||||
|
self._json(401, {"message": "invalid token"})
|
||||||
|
return
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = json.loads(self.rfile.read(length).decode() or "{}")
|
||||||
|
if path == "/api/v1/repos/daniele/rete/issues":
|
||||||
|
self._json(
|
||||||
|
201,
|
||||||
|
{
|
||||||
|
"number": 42,
|
||||||
|
"title": body.get("title"),
|
||||||
|
"state": "open",
|
||||||
|
"html_url": "https://git.loogle.it/daniele/rete/issues/42",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._json(404, {"message": f"not found: {path}"})
|
||||||
|
|
||||||
|
|
||||||
|
def run_mock_tests() -> None:
|
||||||
|
from app.db import init_db
|
||||||
|
from app.knowledge import gitea as gitea_mod
|
||||||
|
from app.mcp import tools
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
gitea_mod._tokens_cache = None
|
||||||
|
|
||||||
|
server = HTTPServer(("127.0.0.1", MOCK_PORT), GiteaMockHandler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
claims = {"sub": "daniele", "scope": "gitea:read gitea:write"}
|
||||||
|
|
||||||
|
repos = tools.call_tool("list_repos", {}, claims)
|
||||||
|
assert "daniele/rete" in repos["content"][0]["text"]
|
||||||
|
|
||||||
|
file_data = tools.call_tool(
|
||||||
|
"get_file",
|
||||||
|
{"repo": "daniele/rete", "path": "ha/RUNBOOK-failover.md"},
|
||||||
|
claims,
|
||||||
|
)
|
||||||
|
assert "tier-b" in file_data["content"][0]["text"]
|
||||||
|
|
||||||
|
search = tools.call_tool(
|
||||||
|
"search_code",
|
||||||
|
{"query": "tier-b", "repo": "daniele/rete"},
|
||||||
|
claims,
|
||||||
|
)
|
||||||
|
assert "RUNBOOK-failover" in search["content"][0]["text"]
|
||||||
|
|
||||||
|
issues = tools.call_tool("list_issues", {"repo": "daniele/rete"}, claims)
|
||||||
|
assert "Test issue" in issues["content"][0]["text"]
|
||||||
|
|
||||||
|
issue = tools.call_tool(
|
||||||
|
"get_issue",
|
||||||
|
{"repo": "daniele/rete", "number": 1},
|
||||||
|
claims,
|
||||||
|
)
|
||||||
|
assert "Corpo issue" in issue["content"][0]["text"]
|
||||||
|
|
||||||
|
created = tools.call_tool(
|
||||||
|
"create_issue",
|
||||||
|
{"repo": "daniele/rete", "title": "Da MCP", "body": "Test"},
|
||||||
|
claims,
|
||||||
|
)
|
||||||
|
assert "42" in created["content"][0]["text"]
|
||||||
|
|
||||||
|
server.shutdown()
|
||||||
|
print("OK mock: list_repos, get_file, search_code, list_issues, get_issue, create_issue")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv() -> None:
|
||||||
|
env_path = os.path.join(ROOT, ".env")
|
||||||
|
if not os.path.isfile(env_path):
|
||||||
|
return
|
||||||
|
with open(env_path, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip("'").strip('"')
|
||||||
|
if key.startswith("GITEA_"):
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def run_live_tests() -> None:
|
||||||
|
_load_dotenv()
|
||||||
|
from app.knowledge import gitea as gitea_mod
|
||||||
|
from app.mcp import tools
|
||||||
|
|
||||||
|
token = os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip()
|
||||||
|
if not token or token == MOCK_TOKEN:
|
||||||
|
print("SKIP live: GITEA_API_TOKEN_DANIELE non impostato (token reale in .env)")
|
||||||
|
return
|
||||||
|
|
||||||
|
api_url = gitea_mod.api_base_url()
|
||||||
|
if str(MOCK_PORT) in api_url:
|
||||||
|
print("SKIP live: GITEA_API_URL punta al mock")
|
||||||
|
return
|
||||||
|
|
||||||
|
gitea_mod._tokens_cache = None
|
||||||
|
claims = {"sub": "daniele", "scope": "gitea:read gitea:write"}
|
||||||
|
|
||||||
|
repos = gitea_mod.list_repos(username="daniele")
|
||||||
|
if not repos.get("repos"):
|
||||||
|
raise RuntimeError("live list_repos: nessun repository")
|
||||||
|
|
||||||
|
first = repos["repos"][0]["full_name"]
|
||||||
|
tools.call_tool("list_repos", {}, claims)
|
||||||
|
print(f"OK live list_repos: {len(repos['repos'])} repo, primo={first}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
run_mock_tests()
|
||||||
|
run_live_tests()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Test P4: scrittura Gitea (create repo, create/update file)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv() -> None:
|
||||||
|
path = os.path.join(ROOT, ".env")
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, _, v = line.partition("=")
|
||||||
|
os.environ[k.strip()] = v.strip().strip("'").strip('"')
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
_load_dotenv()
|
||||||
|
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
|
||||||
|
|
||||||
|
from app.db import init_db
|
||||||
|
from app.knowledge import gitea
|
||||||
|
from app.mcp import tools
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
gitea._tokens_cache = None
|
||||||
|
|
||||||
|
if not os.environ.get("GITEA_API_TOKEN_DANIELE"):
|
||||||
|
print("ERR: token daniele assente")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
claims = {"sub": "daniele", "scope": "gitea:read gitea:write knowledge:read admin"}
|
||||||
|
|
||||||
|
test_repo = "daniele/mcp-p4-test"
|
||||||
|
test_path = "docs/p4-verify.md"
|
||||||
|
test_content = "# P4 verify\n\nFile creato da test_gitea_p4.py\n"
|
||||||
|
|
||||||
|
# cleanup file if exists from prior run
|
||||||
|
try:
|
||||||
|
gitea.get_file(test_repo, test_path, username="daniele")
|
||||||
|
gitea.create_or_update_file(
|
||||||
|
test_repo,
|
||||||
|
test_path,
|
||||||
|
test_content + "\n(updated)\n",
|
||||||
|
"test p4 update",
|
||||||
|
username="daniele",
|
||||||
|
)
|
||||||
|
action = "update"
|
||||||
|
except FileNotFoundError:
|
||||||
|
# ensure repo exists - use temp repo name under daniele
|
||||||
|
repos = {r["full_name"] for r in gitea.list_repos(username="daniele")["repos"]}
|
||||||
|
if test_repo not in repos:
|
||||||
|
created = tools.call_tool(
|
||||||
|
"create_gitea_repo",
|
||||||
|
{
|
||||||
|
"name": "mcp-p4-test",
|
||||||
|
"private": True,
|
||||||
|
"description": "Repo temporaneo test P4",
|
||||||
|
},
|
||||||
|
claims,
|
||||||
|
)
|
||||||
|
print("create_repo", created["content"][0]["text"][:200])
|
||||||
|
written = tools.call_tool(
|
||||||
|
"create_or_update_file",
|
||||||
|
{
|
||||||
|
"repo": test_repo,
|
||||||
|
"path": test_path,
|
||||||
|
"content": test_content,
|
||||||
|
"message": "test p4 create",
|
||||||
|
"reindex": False,
|
||||||
|
},
|
||||||
|
claims,
|
||||||
|
)
|
||||||
|
payload = json.loads(written["content"][0]["text"])
|
||||||
|
action = payload.get("action")
|
||||||
|
print("write", action, payload.get("path"))
|
||||||
|
|
||||||
|
read = gitea.get_file(test_repo, test_path, username="daniele")
|
||||||
|
assert "P4 verify" in read.get("content", ""), read
|
||||||
|
print("read_ok", read["path"])
|
||||||
|
|
||||||
|
for user, repo in (("davide", "davide/progetti"), ("luca", "luca/progetti")):
|
||||||
|
token_key = f"GITEA_API_TOKEN_{user.upper()}"
|
||||||
|
if not os.environ.get(token_key):
|
||||||
|
print(f"SKIP {user}: no token")
|
||||||
|
continue
|
||||||
|
gitea._tokens_cache = None
|
||||||
|
listed = gitea.list_repos(username=user)
|
||||||
|
names = {r["full_name"] for r in listed["repos"]}
|
||||||
|
if repo not in names:
|
||||||
|
raise RuntimeError(f"repo mancante per {user}: {repo}")
|
||||||
|
print(f"{user}_repo_ok", repo)
|
||||||
|
|
||||||
|
print("OK P4: create_or_update_file + repos davide/luca/progetti")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Test P3: RAG semantico su file Gitea indicizzati."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv() -> None:
|
||||||
|
env_path = os.path.join(ROOT, ".env")
|
||||||
|
if not os.path.isfile(env_path):
|
||||||
|
return
|
||||||
|
with open(env_path, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
if key and key not in os.environ:
|
||||||
|
os.environ[key] = value.strip().strip("'").strip('"')
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_paths() -> str:
|
||||||
|
in_container = os.path.isfile("/.dockerenv") or (
|
||||||
|
os.path.isdir("/data") and os.access("/data", os.W_OK)
|
||||||
|
)
|
||||||
|
if in_container:
|
||||||
|
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
|
||||||
|
os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
|
||||||
|
return "container"
|
||||||
|
os.environ["MCP_DB"] = "/tmp/loogle_mcp_p3_test.db"
|
||||||
|
os.environ["MCP_VECTOR_FALLBACK"] = "/tmp/loogle_mcp_p3_vectors.db"
|
||||||
|
return "host"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
_load_dotenv()
|
||||||
|
mode = _configure_paths()
|
||||||
|
print(f"mode={mode} db={os.environ['MCP_DB']} vector={os.environ['MCP_VECTOR_FALLBACK']}")
|
||||||
|
|
||||||
|
from app.db import get_conn, init_db
|
||||||
|
from app.knowledge import gitea_indexer
|
||||||
|
from app.mcp import tools
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
if not get_conn().execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'"
|
||||||
|
).fetchone():
|
||||||
|
raise RuntimeError("tabella indexed_gitea_files assente")
|
||||||
|
|
||||||
|
if not os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip():
|
||||||
|
print("SKIP: GITEA_API_TOKEN_DANIELE assente")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
repo = "daniele/rete"
|
||||||
|
existing = gitea_indexer.list_indexed_files(limit=5, repo=repo)
|
||||||
|
stats = gitea_indexer.index_stats()
|
||||||
|
if len(existing) < 1 or stats.get("qdrant_points", 0) < 1:
|
||||||
|
index_result = gitea_indexer.index_repo(
|
||||||
|
repo,
|
||||||
|
username="daniele",
|
||||||
|
force=True,
|
||||||
|
max_files=1,
|
||||||
|
)
|
||||||
|
print("index", json.dumps(index_result, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
print("index_skip", "already", len(existing), "files")
|
||||||
|
|
||||||
|
files = gitea_indexer.list_indexed_files(limit=5, repo=repo)
|
||||||
|
if not files:
|
||||||
|
raise RuntimeError("indexed_gitea_files vuota")
|
||||||
|
print("indexed_sample", [f["path"] for f in files[:3]])
|
||||||
|
|
||||||
|
claims = {"sub": "daniele", "scope": "knowledge:read gitea:read admin", "admin": "admin"}
|
||||||
|
for query in ("failover tier-b", "runbook"):
|
||||||
|
hits = gitea_indexer.search_gitea_knowledge("daniele", query, limit=5, is_admin=True)
|
||||||
|
if not hits:
|
||||||
|
raise RuntimeError(f"search_gitea_knowledge senza risultati per: {query}")
|
||||||
|
top = hits[0]
|
||||||
|
print(
|
||||||
|
f"search_ok[{query}]",
|
||||||
|
top.get("path") or top.get("title"),
|
||||||
|
round(float(top.get("score", 0)), 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_out = tools.call_tool(
|
||||||
|
"search_gitea_knowledge",
|
||||||
|
{"query": "failover", "limit": 5},
|
||||||
|
claims,
|
||||||
|
)
|
||||||
|
if not json.loads(tool_out["content"][0]["text"]).get("results"):
|
||||||
|
raise RuntimeError("tool search_gitea_knowledge vuoto")
|
||||||
|
|
||||||
|
unified = tools.call_tool(
|
||||||
|
"search_knowledge",
|
||||||
|
{"query": "FAILOVER CENSIMENTO documenti servizi", "limit": 12},
|
||||||
|
claims,
|
||||||
|
)
|
||||||
|
unified_payload = json.loads(unified["content"][0]["text"])
|
||||||
|
results = unified_payload.get("results") or []
|
||||||
|
sources = set()
|
||||||
|
for r in results:
|
||||||
|
if r.get("repo"):
|
||||||
|
sources.add("gitea")
|
||||||
|
elif r.get("source"):
|
||||||
|
sources.add(r["source"])
|
||||||
|
else:
|
||||||
|
sources.add("paperless")
|
||||||
|
print("search_knowledge_sources", sorted(sources))
|
||||||
|
if "gitea" not in sources:
|
||||||
|
raise RuntimeError("search_knowledge non include risultati Gitea")
|
||||||
|
|
||||||
|
list_tool = tools.call_tool("list_gitea_indexed_files", {"repo": repo, "limit": 5}, claims)
|
||||||
|
if json.loads(list_tool["content"][0]["text"]).get("count", 0) <= 0:
|
||||||
|
raise RuntimeError("list_gitea_indexed_files vuoto")
|
||||||
|
|
||||||
|
print("OK P3: search + unified + list (reindex opzionale via tool reindex_gitea_repo)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Verifica P5: tool live Loogle Casa + Home Assistant."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
from app.integrations import casa, homeassistant
|
||||||
|
from app.mcp import tools
|
||||||
|
|
||||||
|
user = os.environ.get("MCP_TEST_USER", "daniele")
|
||||||
|
claims = {
|
||||||
|
"sub": user,
|
||||||
|
"scope": "home:read context:read knowledge:read gitea:read admin",
|
||||||
|
}
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
print("=== P5 integrations ===")
|
||||||
|
print("casa configured:", casa.is_configured(user))
|
||||||
|
print("ha configured:", homeassistant.is_configured())
|
||||||
|
|
||||||
|
if casa.is_configured(user):
|
||||||
|
for tool, args in (
|
||||||
|
("get_home_dashboard", {}),
|
||||||
|
("get_home_weather", {}),
|
||||||
|
("get_network_overview", {}),
|
||||||
|
("get_network_failover_status", {}),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
out = tools.call_tool(tool, args, claims)
|
||||||
|
text = out["content"][0]["text"]
|
||||||
|
data = json.loads(text)
|
||||||
|
print(f"OK {tool}: keys={list(data.keys())[:8]}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"FAIL {tool}: {exc}")
|
||||||
|
errors.append(tool)
|
||||||
|
else:
|
||||||
|
errors.append("casa-not-configured")
|
||||||
|
|
||||||
|
if homeassistant.is_configured():
|
||||||
|
try:
|
||||||
|
cfg = homeassistant.get_config()
|
||||||
|
print(f"OK ha config: version={cfg.get('version')}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"FAIL ha config: {exc}")
|
||||||
|
errors.append("ha-config")
|
||||||
|
try:
|
||||||
|
out = tools.call_tool("list_ha_entities", {"domain": "switch", "limit": 5}, claims)
|
||||||
|
data = json.loads(out["content"][0]["text"])
|
||||||
|
print(f"OK list_ha_entities: count={len(data.get('entities', []))}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"FAIL list_ha_entities: {exc}")
|
||||||
|
errors.append("list_ha_entities")
|
||||||
|
else:
|
||||||
|
errors.append("ha-not-configured")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("P5 FAILED:", errors)
|
||||||
|
return 1
|
||||||
|
print("P5 OK")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Verifica P6: tool live Irrigazione + Turni."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
from app.integrations import irrigazione, turni
|
||||||
|
from app.mcp import tools
|
||||||
|
|
||||||
|
user = os.environ.get("MCP_TEST_USER", "daniele")
|
||||||
|
claims = {
|
||||||
|
"sub": user,
|
||||||
|
"scope": "irrigation:read turni:read home:read admin",
|
||||||
|
}
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
print("=== P6 integrations ===")
|
||||||
|
print("irrigazione configured:", irrigazione.is_configured(user))
|
||||||
|
print("turni configured:", turni.is_configured(user))
|
||||||
|
|
||||||
|
if irrigazione.is_configured(user):
|
||||||
|
for tool, args in (
|
||||||
|
("get_irrigation_status", {}),
|
||||||
|
("get_irrigation_zones", {}),
|
||||||
|
("get_irrigation_history", {"limit": 5}),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
out = tools.call_tool(tool, args, claims)
|
||||||
|
data = json.loads(out["content"][0]["text"])
|
||||||
|
print(f"OK {tool}: type={type(data).__name__}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"FAIL {tool}: {exc}")
|
||||||
|
errors.append(tool)
|
||||||
|
else:
|
||||||
|
errors.append("irrigazione-not-configured")
|
||||||
|
|
||||||
|
try:
|
||||||
|
out = tools.call_tool("get_turni_status", {}, claims)
|
||||||
|
data = json.loads(out["content"][0]["text"])
|
||||||
|
print(f"OK get_turni_status: build={data.get('buildVersion', '')[:30]}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"FAIL get_turni_status: {exc}")
|
||||||
|
errors.append("get_turni_status")
|
||||||
|
|
||||||
|
if turni.is_configured(user):
|
||||||
|
for tool, args in (
|
||||||
|
("list_turni_doctors", {}),
|
||||||
|
("get_my_shifts", {"limit": 10}),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
out = tools.call_tool(tool, args, claims)
|
||||||
|
data = json.loads(out["content"][0]["text"])
|
||||||
|
print(f"OK {tool}: keys={list(data.keys())[:6]}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"FAIL {tool}: {exc}")
|
||||||
|
errors.append(tool)
|
||||||
|
else:
|
||||||
|
errors.append("turni-not-configured")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("P6 FAILED:", errors)
|
||||||
|
return 1
|
||||||
|
print("P6 OK")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Verifica P7: RAG Irrigazione + Turni."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
from app.db import init_db
|
||||||
|
from app.knowledge import apps_indexer, indexer
|
||||||
|
from app.mcp import tools
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
user = os.environ.get("MCP_TEST_USER", "daniele")
|
||||||
|
claims = {
|
||||||
|
"sub": user,
|
||||||
|
"scope": "knowledge:read irrigation:read turni:read admin",
|
||||||
|
}
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
print("=== P7 apps RAG ===")
|
||||||
|
existing = apps_indexer.list_indexed_records(limit=5)
|
||||||
|
if len(existing) >= 2:
|
||||||
|
print(f"skip re-index: {len(existing)} records already present")
|
||||||
|
result = {"irrigazione": {"indexed": len(existing), "skipped_reindex": True}, "turni": {}}
|
||||||
|
else:
|
||||||
|
result = {
|
||||||
|
"irrigazione": apps_indexer.index_irrigazione(history_limit=0, events_limit=0),
|
||||||
|
"turni": apps_indexer.index_turni(assignments_limit=3),
|
||||||
|
}
|
||||||
|
print("index_all:", json.dumps(result, ensure_ascii=False)[:500])
|
||||||
|
|
||||||
|
irr = result.get("irrigazione", {})
|
||||||
|
turn = result.get("turni", {})
|
||||||
|
if irr.get("skipped") and turn.get("skipped"):
|
||||||
|
print("P7 FAILED: both sources skipped")
|
||||||
|
return 1
|
||||||
|
if irr.get("indexed", 0) + turn.get("indexed", 0) < 2 and not irr.get("skipped_reindex"):
|
||||||
|
errors.append("insufficient-indexed-records")
|
||||||
|
|
||||||
|
records = apps_indexer.list_indexed_records(limit=10)
|
||||||
|
print(f"indexed records: {len(records)}")
|
||||||
|
if not records:
|
||||||
|
errors.append("no-records")
|
||||||
|
|
||||||
|
try:
|
||||||
|
hits = apps_indexer.search_apps_knowledge("irrigazione zona prato", limit=3)
|
||||||
|
print(f"search_apps_knowledge irrigazione: {len(hits)} hits")
|
||||||
|
if not hits:
|
||||||
|
errors.append("search-irrigazione-empty")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"FAIL search_apps: {exc}")
|
||||||
|
errors.append("search_apps")
|
||||||
|
|
||||||
|
try:
|
||||||
|
out = tools.call_tool("search_apps_knowledge", {"query": "turno guardia", "limit": 3}, claims)
|
||||||
|
data = json.loads(out["content"][0]["text"])
|
||||||
|
print(f"OK tool search_apps_knowledge: {len(data.get('results', []))} hits")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"FAIL tool search_apps_knowledge: {exc}")
|
||||||
|
errors.append("tool-search")
|
||||||
|
|
||||||
|
try:
|
||||||
|
combined = indexer.search_knowledge(user, "irrigazione valvola", limit=5, is_admin=True)
|
||||||
|
apps_hits = [h for h in combined if h.get("source") in ("irrigazione", "turni")]
|
||||||
|
print(f"search_knowledge includes apps: {len(apps_hits)} app hits / {len(combined)} total")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"FAIL search_knowledge: {exc}")
|
||||||
|
errors.append("search_knowledge")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("P7 FAILED:", errors)
|
||||||
|
return 1
|
||||||
|
print("P7 OK")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from app.db import init_db
|
||||||
|
from app.knowledge.apps_indexer import (
|
||||||
|
_summarize_irrigation_status,
|
||||||
|
_index_text,
|
||||||
|
list_indexed_records,
|
||||||
|
search_apps_knowledge,
|
||||||
|
)
|
||||||
|
from app.integrations import irrigazione
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
print("1 db ok")
|
||||||
|
existing = list_indexed_records(source="irrigazione", limit=1)
|
||||||
|
if existing and existing[0].get("record_id") == "status-snapshot":
|
||||||
|
print("2 skip index (status-snapshot exists)")
|
||||||
|
else:
|
||||||
|
s = irrigazione.get_status("daniele")
|
||||||
|
print("3 status ok", len(s))
|
||||||
|
text = _summarize_irrigation_status(s)
|
||||||
|
print("4 summary", len(text))
|
||||||
|
r = _index_text("irrigazione", "status-snapshot", "Stato", text)
|
||||||
|
print("5 indexed", r)
|
||||||
|
hits = search_apps_knowledge("irrigazione zona", limit=2)
|
||||||
|
print("6 hits", len(hits))
|
||||||
|
print("STEP OK")
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Test P2: collegamento progetti MCP ↔ repository Gitea."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
os.environ["MCP_DB"] = "/tmp/loogle_mcp_p2_test.db"
|
||||||
|
TEST_ROOT = tempfile.mkdtemp(prefix="mcp-p2-")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv() -> None:
|
||||||
|
env_path = os.path.join(ROOT, ".env")
|
||||||
|
if not os.path.isfile(env_path):
|
||||||
|
return
|
||||||
|
with open(env_path, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
os.environ.setdefault(key.strip(), value.strip().strip("'").strip('"'))
|
||||||
|
|
||||||
|
|
||||||
|
def run_unit_tests() -> None:
|
||||||
|
os.environ["MCP_CONTEXT_ROOT"] = TEST_ROOT
|
||||||
|
from app.db import init_db
|
||||||
|
from app.context import store as context_store
|
||||||
|
from app.mcp import tools
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
claims = {
|
||||||
|
"sub": "daniele",
|
||||||
|
"scope": "context:read context:write gitea:read gitea:write",
|
||||||
|
}
|
||||||
|
|
||||||
|
meta = context_store.create_project(
|
||||||
|
"daniele",
|
||||||
|
"Progetto unit test",
|
||||||
|
tags=["test"],
|
||||||
|
)
|
||||||
|
meta_path = os.path.join(TEST_ROOT, "daniele", "projects", meta["id"], "meta.json")
|
||||||
|
meta["gitea_repo"] = "daniele/rete"
|
||||||
|
with open(meta_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
ctx = tools.call_tool(
|
||||||
|
"get_project_context",
|
||||||
|
{"project_id": meta["id"], "include_gitea": False},
|
||||||
|
claims,
|
||||||
|
)
|
||||||
|
payload = json.loads(ctx["content"][0]["text"])
|
||||||
|
assert payload["meta"]["gitea_repo"] == "daniele/rete"
|
||||||
|
|
||||||
|
unlinked = tools.call_tool(
|
||||||
|
"link_project_repo",
|
||||||
|
{"project_id": meta["id"], "gitea_repo": ""},
|
||||||
|
claims,
|
||||||
|
)
|
||||||
|
payload = json.loads(unlinked["content"][0]["text"])
|
||||||
|
assert "gitea_repo" not in payload
|
||||||
|
|
||||||
|
print("OK unit: meta gitea_repo + scollegamento locale")
|
||||||
|
|
||||||
|
|
||||||
|
def run_live_tests() -> None:
|
||||||
|
_load_dotenv()
|
||||||
|
token = os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip()
|
||||||
|
if not token:
|
||||||
|
print("SKIP live: GITEA_API_TOKEN_DANIELE assente")
|
||||||
|
return
|
||||||
|
|
||||||
|
live_root = os.path.join(TEST_ROOT, "live")
|
||||||
|
os.makedirs(live_root, exist_ok=True)
|
||||||
|
os.environ["MCP_CONTEXT_ROOT"] = live_root
|
||||||
|
|
||||||
|
from app.context import store as context_store
|
||||||
|
from app.mcp import tools
|
||||||
|
from app.knowledge import gitea
|
||||||
|
|
||||||
|
gitea._tokens_cache = None
|
||||||
|
claims = {
|
||||||
|
"sub": "daniele",
|
||||||
|
"scope": "context:read context:write gitea:read gitea:write",
|
||||||
|
}
|
||||||
|
|
||||||
|
project_id = "rete-ha-p2-test"
|
||||||
|
proj_dir = os.path.join(live_root, "daniele", "projects", project_id)
|
||||||
|
if os.path.isdir(proj_dir):
|
||||||
|
shutil.rmtree(proj_dir)
|
||||||
|
|
||||||
|
created = tools.call_tool(
|
||||||
|
"create_project",
|
||||||
|
{
|
||||||
|
"title": "Rete HA P2 test",
|
||||||
|
"tags": ["infra", "test"],
|
||||||
|
"gitea_repo": "daniele/rete",
|
||||||
|
"seed_from_gitea": True,
|
||||||
|
},
|
||||||
|
claims,
|
||||||
|
)
|
||||||
|
payload = json.loads(created["content"][0]["text"])
|
||||||
|
project_id = payload["id"]
|
||||||
|
assert payload.get("gitea_repo") == "daniele/rete"
|
||||||
|
assert payload.get("gitea_seeded_at")
|
||||||
|
|
||||||
|
ctx_path = os.path.join(live_root, "daniele", "projects", project_id, "context.md")
|
||||||
|
context_md = open(ctx_path, encoding="utf-8").read()
|
||||||
|
assert "seed:gitea daniele/rete" in context_md
|
||||||
|
assert len(context_md) > 200
|
||||||
|
|
||||||
|
enriched = tools.call_tool(
|
||||||
|
"get_project_context",
|
||||||
|
{"project_id": project_id, "include_gitea": True, "session_limit": 0},
|
||||||
|
claims,
|
||||||
|
)
|
||||||
|
data = json.loads(enriched["content"][0]["text"])
|
||||||
|
assert data["meta"]["gitea_repo"] == "daniele/rete"
|
||||||
|
assert data["gitea"]["available"] is True
|
||||||
|
assert data["gitea"]["readme"] is not None
|
||||||
|
assert isinstance(data["gitea"].get("docs_files"), list)
|
||||||
|
|
||||||
|
link_existing = tools.call_tool(
|
||||||
|
"link_project_repo",
|
||||||
|
{"project_id": project_id, "gitea_repo": "daniele/rete", "seed_from_gitea": True},
|
||||||
|
claims,
|
||||||
|
)
|
||||||
|
meta2 = json.loads(link_existing["content"][0]["text"])
|
||||||
|
assert "gitea_seeded_at" in meta2
|
||||||
|
|
||||||
|
shutil.rmtree(proj_dir)
|
||||||
|
print(f"OK live: create+seed+enrichment su daniele/rete (project {project_id})")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
run_unit_tests()
|
||||||
|
run_live_tests()
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(TEST_ROOT, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json, os, sys
|
||||||
|
os.environ.setdefault('MCP_DB','/data/loogle_mcp.db')
|
||||||
|
os.environ.setdefault('MCP_VECTOR_FALLBACK','/data/vector_fallback.db')
|
||||||
|
from app.db import init_db, get_conn
|
||||||
|
from app.knowledge import gitea_indexer
|
||||||
|
from app.mcp import tools
|
||||||
|
init_db()
|
||||||
|
assert get_conn().execute("SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'").fetchone()
|
||||||
|
repo='daniele/rete'
|
||||||
|
for path in ['ha/FAILOVER-VOLUMES.md','ha/RUNBOOK-failover.md']:
|
||||||
|
r=gitea_indexer.index_file(repo, path, username='daniele', private=True, force=True)
|
||||||
|
print('indexed', path, r.get('chunks'), r.get('skipped'), flush=True)
|
||||||
|
files=gitea_indexer.list_indexed_files(limit=10, repo=repo)
|
||||||
|
print('db_files', [f['path'] for f in files], flush=True)
|
||||||
|
for q in ('failover tier-b','runbook'):
|
||||||
|
hits=gitea_indexer.search_gitea_knowledge('daniele', q, limit=3, is_admin=True)
|
||||||
|
print('search', q, hits[0]['path'] if hits else None, round(float(hits[0]['score']),3) if hits else None, flush=True)
|
||||||
|
if not hits: sys.exit(1)
|
||||||
|
claims={'sub':'daniele','scope':'knowledge:read gitea:read admin'}
|
||||||
|
for tool,args in [
|
||||||
|
('search_gitea_knowledge',{'query':'failover tier-b','limit':5}),
|
||||||
|
('list_gitea_indexed_files',{'repo':repo,'limit':10}),
|
||||||
|
('reindex_gitea_repo',{'repo':repo,'max_files':1}),
|
||||||
|
('search_knowledge',{'query':'failover keepalived','limit':8}),
|
||||||
|
]:
|
||||||
|
out=tools.call_tool(tool,args,claims)
|
||||||
|
p=json.loads(out['content'][0]['text'])
|
||||||
|
if tool=='search_knowledge':
|
||||||
|
src=sorted({r.get('source') for r in p['results']})
|
||||||
|
print(tool, src, flush=True)
|
||||||
|
assert 'gitea' in src
|
||||||
|
elif tool=='list_gitea_indexed_files':
|
||||||
|
print(tool, p['count'], flush=True)
|
||||||
|
else:
|
||||||
|
print(tool, 'ok', flush=True)
|
||||||
|
print('OK P3 COMPLETE', flush=True)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json, os, sys
|
||||||
|
os.environ.setdefault('MCP_DB','/data/loogle_mcp.db')
|
||||||
|
os.environ.setdefault('MCP_VECTOR_FALLBACK','/data/vector_fallback.db')
|
||||||
|
from app.db import init_db, get_conn
|
||||||
|
from app.knowledge import gitea_indexer
|
||||||
|
from app.mcp import tools
|
||||||
|
init_db()
|
||||||
|
print('table_ok', bool(get_conn().execute("SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'").fetchone()), flush=True)
|
||||||
|
files=gitea_indexer.list_indexed_files(limit=10, repo='daniele/rete')
|
||||||
|
print('indexed', [f['path'] for f in files], flush=True)
|
||||||
|
if not files:
|
||||||
|
print('WARN: nessun file indicizzato — indicizzo stacks.conf (piccolo)', flush=True)
|
||||||
|
r=gitea_indexer.index_file('daniele/rete','ha/stacks.conf', username='daniele', private=True, force=True)
|
||||||
|
print('new_index', r, flush=True)
|
||||||
|
files=gitea_indexer.list_indexed_files(limit=10, repo='daniele/rete')
|
||||||
|
for q in ('failover','censimento','tier-b'):
|
||||||
|
hits=gitea_indexer.search_gitea_knowledge('daniele', q, limit=3, is_admin=True)
|
||||||
|
print('search', q, hits[0]['path'] if hits else 'NONE', round(float(hits[0]['score']),3) if hits else None, flush=True)
|
||||||
|
if not hits and q=='failover':
|
||||||
|
sys.exit(1)
|
||||||
|
claims={'sub':'daniele','scope':'knowledge:read gitea:read admin'}
|
||||||
|
sg=tools.call_tool('search_gitea_knowledge',{'query':'failover tier-b','limit':5},claims)
|
||||||
|
assert json.loads(sg['content'][0]['text'])['results']
|
||||||
|
print('tool search_gitea_knowledge ok', flush=True)
|
||||||
|
li=tools.call_tool('list_gitea_indexed_files',{'repo':'daniele/rete','limit':10},claims)
|
||||||
|
print('tool list', json.loads(li['content'][0]['text'])['count'], flush=True)
|
||||||
|
ri=tools.call_tool('reindex_gitea_repo',{'repo':'daniele/rete','max_files':1},claims)
|
||||||
|
print('tool reindex', json.loads(ri['content'][0]['text']).get('files_indexed'), flush=True)
|
||||||
|
sk=tools.call_tool('search_knowledge',{'query':'failover keepalived','limit':8},claims)
|
||||||
|
src=sorted({r.get('source') for r in json.loads(sk['content'][0]['text'])['results']})
|
||||||
|
print('tool search_knowledge sources', src, flush=True)
|
||||||
|
assert 'gitea' in src
|
||||||
|
print('OK P3 SEARCH VERIFIED', flush=True)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Background indexer worker — cicli separati Paperless / Gitea / Apps."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||||
|
LOGGER = logging.getLogger("loogle_mcp.worker")
|
||||||
|
|
||||||
|
from app.db import init_db # noqa: E402
|
||||||
|
from app.knowledge import apps_indexer, gitea_indexer, indexer # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _run_gitea() -> None:
|
||||||
|
if os.environ.get("GITEA_INDEX_ENABLED", "yes").strip().lower() in ("0", "false", "no", "off"):
|
||||||
|
LOGGER.info("Indicizzazione Gitea disabilitata (GITEA_INDEX_ENABLED)")
|
||||||
|
return
|
||||||
|
gitea_result = gitea_indexer.index_all()
|
||||||
|
stats = gitea_indexer.index_stats()
|
||||||
|
LOGGER.info("Indicizzazione Gitea completata: %s stats=%s", gitea_result, stats)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_paperless() -> None:
|
||||||
|
result = indexer.index_all(max_pages=10)
|
||||||
|
LOGGER.info("Indicizzazione Paperless completata: %s", result)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_apps() -> None:
|
||||||
|
apps_result = apps_indexer.index_all()
|
||||||
|
LOGGER.info("Indicizzazione Apps (P7) completata: %s", apps_result)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
init_db()
|
||||||
|
interval = int(os.environ.get("INDEXER_INTERVAL_MINUTES", "30")) * 60
|
||||||
|
LOGGER.info("Indexer avviato, intervallo %ds", interval)
|
||||||
|
|
||||||
|
from app.knowledge import thermal # noqa: WPS433
|
||||||
|
|
||||||
|
status = thermal.read_status()
|
||||||
|
if status:
|
||||||
|
LOGGER.info(
|
||||||
|
"Thermal probe OK: temp=%.1f°C load1=%.2f nproc=%s source=%s (hard=%.0f°C soft=%.0f°C cpu_target=%.0f%%)",
|
||||||
|
float(status.get("cpu_temp_c") or 0),
|
||||||
|
float(status.get("load1") or 0),
|
||||||
|
status.get("nproc"),
|
||||||
|
status.get("source"),
|
||||||
|
thermal.hard_temp_c(),
|
||||||
|
thermal.soft_temp_c(),
|
||||||
|
thermal.cpu_target_pct(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
LOGGER.warning("Thermal probe non raggiungibile all'avvio — gate userà delay conservativi")
|
||||||
|
|
||||||
|
# Gitea per primo: repo piccoli, non bloccato da Paperless lento
|
||||||
|
try:
|
||||||
|
thermal.wait_for_headroom(context="cycle:gitea-boot")
|
||||||
|
_run_gitea()
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.error("Indicizzazione Gitea fallita: %s", exc)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
thermal.wait_for_headroom(context="cycle:gitea")
|
||||||
|
_run_gitea()
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.error("Indicizzazione Gitea fallita: %s", exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
thermal.wait_for_headroom(context="cycle:paperless")
|
||||||
|
_run_paperless()
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.error("Indicizzazione Paperless fallita: %s", exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
thermal.wait_for_headroom(context="cycle:apps")
|
||||||
|
_run_apps()
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.error("Indicizzazione Apps fallita: %s", exc)
|
||||||
|
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -19,6 +19,7 @@ EXCLUDED_FILES = {
|
|||||||
"irrigation_cron.log",
|
"irrigation_cron.log",
|
||||||
"road_weather.log",
|
"road_weather.log",
|
||||||
"snow_radar.log",
|
"snow_radar.log",
|
||||||
|
"nowcast_120m_alert.log", # sostituito da meteo-alert (ADR-019, disabilitato 2026-07-25)
|
||||||
}
|
}
|
||||||
SEASONAL_EXCLUDED_FILES = {
|
SEASONAL_EXCLUDED_FILES = {
|
||||||
"freeze_alert.log",
|
"freeze_alert.log",
|
||||||
|
|||||||
Reference in new issue
Block a user