Backup automatico script del 2026-08-23 12:04

This commit is contained in:
daniele committed 2026-08-23 12:04:16 +02:00
1 parent 11823447a2
commit 795a15a7b6
66 files changed
+8546

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,295 @@
# -*- coding: utf-8 -*-
"""RAG su export Irrigazione + Turni (P7)."""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime, timezone
from typing import Any, Optional
from ..db import get_conn
from ..integrations import irrigazione, turni
from . import embeddings, qdrant_store
from .text_chunk import chunk_text as _chunk_text
LOGGER = logging.getLogger("loogle_mcp.apps_indexer")
INDEX_USER = os.environ.get("APPS_INDEX_USER", "daniele").lower()
def _int_env(name: str, default: int) -> int:
try:
return int(os.environ.get(name, str(default)))
except ValueError:
return default
def _enabled() -> bool:
flag = os.environ.get("APPS_INDEX_ENABLED", "yes").strip().lower()
return flag not in ("0", "false", "no", "off")
def _doc_id(source: str, record_id: str) -> int:
key = f"{source}:{record_id}"
return abs(hash(key)) % (2**31 - 1)
def _index_text(source: str, record_id: str, title: str, text: str) -> dict:
chunks = _chunk_text(_truncate(text))
if len(chunks) > 8:
chunks = chunks[:8]
if not chunks:
return {"source": source, "record_id": record_id, "chunks": 0, "skipped": True}
vectors = embeddings.embed_texts(chunks)
collection = qdrant_store.APPS_SHARED_COLLECTION
doc_id = _doc_id(source, record_id)
qdrant_store.delete_by_doc(collection, doc_id)
ids = []
payloads = []
for i, chunk in enumerate(chunks):
point_id = f"app-{source}-{record_id}-chunk-{i}"
ids.append(point_id)
payloads.append(
{
"doc_id": doc_id,
"source": source,
"record_id": record_id,
"chunk_index": i,
"title": title,
"text": chunk,
"owner": "family",
"visibility": "family",
}
)
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
get_conn().execute(
"INSERT INTO indexed_apps_records(source,record_id,title,owner,chunk_count,indexed_at)"
" VALUES (?,?,?,?,?,datetime('now'))"
" ON CONFLICT(source,record_id) DO UPDATE SET"
" title=excluded.title, chunk_count=excluded.chunk_count, indexed_at=datetime('now')",
(source, record_id, title, "family", len(chunks)),
)
get_conn().commit()
return {"source": source, "record_id": record_id, "chunks": len(chunks), "collection": collection}
def _format_irrigation_history_item(item: dict, idx: int) -> str:
parts = [f"Irrigazione storico #{idx}"]
for key in ("started_at", "ended_at", "zone", "zone_name", "duration_min", "volume_l", "mode", "note"):
if item.get(key) is not None:
parts.append(f"{key}: {item[key]}")
return "\n".join(parts)
def _format_irrigation_event(item: dict, idx: int) -> str:
parts = [f"Irrigazione evento #{idx}"]
for key in ("ts", "time", "type", "level", "message", "zone", "detail"):
if item.get(key) is not None:
parts.append(f"{key}: {item[key]}")
return "\n".join(parts)
def _format_turni_assignment(item: dict, idx: int) -> str:
parts = [f"Turno #{idx}"]
for key in (
"date", "startDate", "endDate", "doctorId", "doctorName", "doctor_name",
"slotId", "slotName", "slot_name", "uoc", "uocName", "shiftType", "notes",
):
if item.get(key) is not None:
parts.append(f"{key}: {item[key]}")
return "\n".join(parts)
def _truncate(text: str, limit: int = 6000) -> str:
if len(text) <= limit:
return text
return text[: limit - 20] + "\n… [truncated]"
def _summarize_irrigation_status(status: dict) -> str:
lines = ["Irrigazione — snapshot stato"]
for key in ("plan_mode", "program", "simulation", "hibernation", "draining"):
if key in status:
lines.append(f"{key}: {status[key]}")
ha = status.get("ha") or {}
lines.append(f"ha_connected: {ha.get('connected')}")
zones = status.get("zones") or []
lines.append(f"zone_count: {len(zones)}")
for z in zones[:12]:
if isinstance(z, dict):
lines.append(
f" - {z.get('name', z.get('id'))}: state={z.get('ha_state')} excluded={z.get('excluded')}"
)
analysis = status.get("analysis")
if isinstance(analysis, dict):
for k, v in list(analysis.items())[:8]:
lines.append(f"analysis.{k}: {v}")
elif analysis:
lines.append(f"analysis: {analysis}")
return "\n".join(lines)
def _summarize_zones(zones: Any) -> str:
items = zones if isinstance(zones, list) else _normalize_list(zones)
lines = [f"Irrigazione — zone ({len(items)})"]
for z in items[:20]:
if not isinstance(z, dict):
continue
lines.append(
f"- {z.get('name', z.get('id'))}: ha={z.get('ha_state')} "
f"rate_mmh={z.get('rate_mmh')} flow={z.get('zone_flow_lph')}"
)
return "\n".join(lines)
def _normalize_list(data: Any) -> list:
if isinstance(data, list):
return data
if isinstance(data, dict):
for key in ("items", "results", "history", "events", "assignments", "records"):
val = data.get(key)
if isinstance(val, list):
return val
return []
def index_irrigazione(*, username: Optional[str] = None, history_limit: Optional[int] = None, events_limit: Optional[int] = None) -> dict:
user = username or INDEX_USER
history_limit = history_limit if history_limit is not None else _int_env("APPS_INDEX_HISTORY_LIMIT", 25)
events_limit = events_limit if events_limit is not None else _int_env("APPS_INDEX_EVENTS_LIMIT", 40)
if not irrigazione.is_configured(user):
return {"source": "irrigazione", "skipped": True, "reason": "not configured"}
indexed = 0
errors = 0
try:
status = irrigazione.get_status(user)
status_text = _summarize_irrigation_status(status)
_index_text("irrigazione", "status-snapshot", "Irrigazione — stato attuale", status_text)
indexed += 1
except Exception as exc:
LOGGER.warning("Irrigazione status index failed: %s", exc)
errors += 1
try:
zones = irrigazione.get_zones(user)
zones_text = _summarize_zones(zones)
_index_text("irrigazione", "zones-snapshot", "Irrigazione — zone", zones_text)
indexed += 1
except Exception as exc:
LOGGER.warning("Irrigazione zones index failed: %s", exc)
errors += 1
try:
history = irrigazione.get_history(user, limit=history_limit)
for i, item in enumerate(_normalize_list(history)):
if not isinstance(item, dict):
continue
rid = str(item.get("id") or item.get("started_at") or i)
text = _format_irrigation_history_item(item, i)
_index_text("irrigazione", f"history-{rid}", f"Irrigazione storico {rid}", text)
indexed += 1
except Exception as exc:
LOGGER.warning("Irrigazione history index failed: %s", exc)
errors += 1
try:
events = irrigazione.get_events(user, limit=events_limit)
for i, item in enumerate(_normalize_list(events)):
if not isinstance(item, dict):
continue
rid = str(item.get("id") or item.get("ts") or item.get("time") or i)
text = _format_irrigation_event(item, i)
_index_text("irrigazione", f"event-{rid}", f"Irrigazione evento {rid}", text)
indexed += 1
except Exception as exc:
LOGGER.warning("Irrigazione events index failed: %s", exc)
errors += 1
try:
lavori = irrigazione.get_lavori_summary(user)
lavori_text = _truncate(json.dumps(lavori, ensure_ascii=False, indent=2), 4000)
_index_text("irrigazione", "lavori-summary", "Irrigazione — lavori manutenzione", lavori_text)
indexed += 1
except Exception as exc:
LOGGER.warning("Irrigazione lavori index failed: %s", exc)
errors += 1
return {"source": "irrigazione", "indexed": indexed, "errors": errors}
def index_turni(*, username: Optional[str] = None, assignments_limit: Optional[int] = None) -> dict:
user = username or INDEX_USER
assignments_limit = assignments_limit if assignments_limit is not None else _int_env("APPS_INDEX_ASSIGNMENTS_LIMIT", 80)
if not turni.is_configured(user):
return {"source": "turni", "skipped": True, "reason": "not configured"}
indexed = 0
errors = 0
try:
status = turni.get_status()
status_text = _truncate(json.dumps(status, ensure_ascii=False, indent=2), 2000)
_index_text("turni", "status-snapshot", "Turni — stato servizio", status_text)
indexed += 1
except Exception as exc:
LOGGER.warning("Turni status index failed: %s", exc)
errors += 1
try:
doctors = turni.list_doctors(user)
lines = ["Turni — medici"]
for d in (_normalize_list(doctors) if not isinstance(doctors, list) else doctors)[:40]:
if isinstance(d, dict):
lines.append(f"- {d.get('name', d.get('fullName'))} id={d.get('id')}")
_index_text("turni", "doctors-list", "Turni — elenco medici", "\n".join(lines))
indexed += 1
except Exception as exc:
LOGGER.warning("Turni doctors index failed: %s", exc)
errors += 1
try:
assignments = turni.get_shift_assignments(user, limit=assignments_limit)
for i, item in enumerate(_normalize_list(assignments)):
if not isinstance(item, dict):
continue
rid = str(item.get("id") or item.get("date") or i)
text = _format_turni_assignment(item, i)
_index_text("turni", f"assignment-{rid}", f"Turno {rid}", text)
indexed += 1
except Exception as exc:
LOGGER.warning("Turni assignments index failed: %s", exc)
errors += 1
return {"source": "turni", "indexed": indexed, "errors": errors}
def index_all(*, username: Optional[str] = None) -> dict:
if not _enabled():
return {"skipped": True, "reason": "APPS_INDEX_ENABLED=no"}
user = username or INDEX_USER
return {
"irrigazione": index_irrigazione(username=user),
"turni": index_turni(username=user),
"at": datetime.now(timezone.utc).isoformat(),
}
def search_apps_knowledge(query: str, limit: int = 8, source: Optional[str] = None) -> list:
vectors = embeddings.embed_texts([query])
flt = {"source": source} if source else None
hits = qdrant_store.search(
[qdrant_store.APPS_SHARED_COLLECTION],
vectors[0],
limit=limit,
visibility_filter=flt,
)
for hit in hits:
hit.setdefault("source_type", hit.get("source", "apps"))
return hits
def list_indexed_records(source: Optional[str] = None, limit: int = 40) -> list:
conn = get_conn()
if source:
rows = conn.execute(
"SELECT * FROM indexed_apps_records WHERE source=? ORDER BY indexed_at DESC LIMIT ?",
(source, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM indexed_apps_records ORDER BY indexed_at DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""Embedding providers — con thermal gate e keep_alive adattivo."""
import logging
import os
import time
from typing import Optional
import httpx
from . import thermal
LOGGER = logging.getLogger("loogle_mcp.embeddings")
def embed_texts(texts: list[str]) -> list[list[float]]:
if not texts:
return []
ollama_url = os.environ.get("OLLAMA_URL", "").strip()
if ollama_url:
try:
return _embed_ollama(texts, ollama_url)
except Exception as exc:
LOGGER.warning("Ollama embedding failed: %s", exc)
openai_key = os.environ.get("OPENAI_API_KEY", "").strip()
if openai_key:
return _embed_openai(texts, openai_key)
raise RuntimeError("Nessun provider embedding configurato (OLLAMA_URL o OPENAI_API_KEY)")
def _embed_ollama(texts: list[str], base_url: str) -> list[list[float]]:
model = os.environ.get("OLLAMA_EMBED_MODEL", "nomic-embed-text")
vectors = []
timeout = httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)
with httpx.Client(timeout=timeout) as client:
for i, text in enumerate(texts):
status = thermal.wait_for_headroom(context=f"embed:{i+1}/{len(texts)}")
keep_alive = thermal.suggested_keep_alive(status)
delay = thermal.suggested_delay_s(status)
payload = {"model": model, "prompt": text, "keep_alive": keep_alive}
# options.num_thread limita i thread CPU lato Ollama (se supportato)
num_thread = os.environ.get("OLLAMA_NUM_THREAD", "").strip()
if num_thread:
try:
payload["options"] = {"num_thread": int(num_thread)}
except ValueError:
pass
resp = client.post(f"{base_url.rstrip('/')}/api/embeddings", json=payload)
if resp.status_code >= 400:
LOGGER.warning(
"Ollama embeddings HTTP %s: %s — payload keys=%s",
resp.status_code,
resp.text[:300],
list(payload.keys()),
)
resp.raise_for_status()
vectors.append(resp.json()["embedding"])
if delay > 0 and i + 1 < len(texts):
time.sleep(delay)
# Unload solo se esplicitamente richiesto (zona HARD) — evita spike da reload
if keep_alive == 0:
try:
client.post(
f"{base_url.rstrip('/')}/api/generate",
json={"model": model, "keep_alive": 0},
timeout=30.0,
)
except Exception:
pass
return vectors
def _embed_openai(texts: list[str], api_key: str) -> list[list[float]]:
model = os.environ.get("OPENAI_EMBED_MODEL", "text-embedding-3-small")
with httpx.Client(timeout=120.0) as client:
resp = client.post(
"https://api.openai.com/v1/embeddings",
headers={"Authorization": f"Bearer {api_key}"},
json={"model": model, "input": texts},
)
resp.raise_for_status()
data = resp.json()["data"]
return [item["embedding"] for item in sorted(data, key=lambda x: x["index"])]
+495
View File
@@ -0,0 +1,495 @@
# -*- coding: utf-8 -*-
"""Gitea REST API client — token per utente MCP."""
import base64
import json
import logging
import os
import re
from typing import Any, Optional
from urllib.parse import quote
import httpx
LOGGER = logging.getLogger("loogle_mcp.gitea")
GITEA_URL = os.environ.get("GITEA_URL", "https://git.loogle.it").rstrip("/")
MCP_USERS = ("daniele", "lucia", "davide", "luca")
GITEA_API_TOKEN_SCOPES = "read:repository,write:repository,write:issue,write:user,read:user"
TEXT_EXTENSIONS = {
".md", ".txt", ".py", ".sh", ".yml", ".yaml", ".json", ".toml", ".ini",
".conf", ".js", ".ts", ".tsx", ".jsx", ".html", ".css", ".sql", ".go",
".rs", ".env", ".service", ".timer", ".xml", ".csv",
}
_tokens_cache: Optional[dict[str, str]] = None
def _load_tokens() -> dict[str, str]:
global _tokens_cache
if _tokens_cache is not None:
return _tokens_cache
tokens: dict[str, str] = {}
json_map = os.environ.get("GITEA_API_TOKENS", "").strip()
if json_map:
try:
parsed = json.loads(json_map)
if isinstance(parsed, dict):
tokens.update({k.lower(): v for k, v in parsed.items() if v})
except json.JSONDecodeError:
LOGGER.warning("GITEA_API_TOKENS non è JSON valido")
fallback = os.environ.get("GITEA_API_TOKEN", "").strip()
for user in MCP_USERS:
env_key = f"GITEA_API_TOKEN_{user.upper()}"
token = os.environ.get(env_key, "").strip()
if token:
tokens[user] = token
elif user not in tokens and fallback:
tokens[user] = fallback
if not tokens and fallback:
tokens["daniele"] = fallback
_tokens_cache = tokens
return tokens
def list_configured_users() -> list[str]:
return list(_load_tokens().keys())
def is_configured(username: Optional[str] = None) -> bool:
tokens = _load_tokens()
if not tokens:
return False
if username:
user = username.lower()
return user in tokens or "daniele" in tokens or bool(tokens)
return True
def _headers(username: Optional[str] = None) -> dict[str, str]:
tokens = _load_tokens()
if not tokens:
raise RuntimeError(
"Nessun token Gitea configurato. "
"Imposta GITEA_API_TOKEN o GITEA_API_TOKEN_{USER} in .env — vedi docs/GITEA-TOKEN.md"
)
user = (username or "daniele").lower()
token = tokens.get(user) or tokens.get("daniele") or next(iter(tokens.values()))
return {"Authorization": f"token {token}"}
def parse_repo(repo: str) -> tuple[str, str]:
cleaned = repo.strip().strip("/")
if cleaned.count("/") != 1:
raise ValueError("repo deve essere nel formato owner/name (es. daniele/rete)")
owner, name = cleaned.split("/", 1)
if not owner or not name:
raise ValueError("repo deve essere nel formato owner/name (es. daniele/rete)")
return owner, name
def api_base_url() -> str:
return os.environ.get("GITEA_API_URL", GITEA_URL).rstrip("/")
def public_base_url() -> str:
return os.environ.get("GITEA_URL", "https://git.loogle.it").rstrip("/")
def _api_url(path: str) -> str:
return f"{api_base_url()}/api/v1{path}"
def _request(
method: str,
path: str,
*,
username: Optional[str] = None,
params: Optional[dict] = None,
json_body: Optional[dict] = None,
) -> Any:
with httpx.Client(timeout=60.0, verify=True) as client:
resp = client.request(
method,
_api_url(path),
headers=_headers(username),
params=params,
json=json_body,
)
if resp.status_code == 404:
raise FileNotFoundError(resp.text or "Risorsa Gitea non trovata")
resp.raise_for_status()
if resp.content:
return resp.json()
return {}
def list_repos(
username: Optional[str] = None,
page: int = 1,
limit: int = 50,
) -> dict:
data = _request(
"GET",
"/user/repos",
username=username,
params={"page": page, "limit": limit, "sort": "updated"},
)
repos = []
for repo in data if isinstance(data, list) else []:
full_name = repo.get("full_name") or ""
if not full_name and repo.get("owner"):
full_name = f"{repo['owner'].get('login', '')}/{repo.get('name', '')}"
repos.append(
{
"full_name": full_name,
"description": repo.get("description") or "",
"private": bool(repo.get("private")),
"html_url": repo.get("html_url") or f"{public_base_url()}/{full_name}",
"default_branch": repo.get("default_branch") or "main",
"updated_at": repo.get("updated_at"),
}
)
return {"repos": repos, "page": page, "count": len(repos)}
def _decode_content(entry: dict) -> str:
encoding = (entry.get("encoding") or "").lower()
raw = entry.get("content") or ""
if encoding == "base64":
return base64.b64decode(raw).decode("utf-8", errors="replace")
return raw
def get_file(
repo: str,
path: str,
ref: Optional[str] = None,
username: Optional[str] = None,
) -> dict:
owner, name = parse_repo(repo)
file_path = path.lstrip("/")
params = {}
if ref:
params["ref"] = ref
encoded_path = "/".join(quote(part, safe="") for part in file_path.split("/"))
data = _request(
"GET",
f"/repos/{owner}/{name}/contents/{encoded_path}",
username=username,
params=params or None,
)
if isinstance(data, list):
entries = [
{
"name": item.get("name"),
"path": item.get("path"),
"type": item.get("type"),
"size": item.get("size"),
}
for item in data
]
return {
"repo": f"{owner}/{name}",
"path": file_path or "/",
"type": "dir",
"entries": entries,
}
content = _decode_content(data)
return {
"repo": f"{owner}/{name}",
"path": data.get("path") or file_path,
"type": data.get("type") or "file",
"size": data.get("size"),
"sha": data.get("sha"),
"html_url": data.get("html_url") or f"{public_base_url()}/{owner}/{name}/src/branch/{ref or 'main'}/{file_path}",
"content": content,
}
def search_code(
query: str,
repo: Optional[str] = None,
limit: int = 20,
username: Optional[str] = None,
) -> dict:
q = query.strip()
if not q:
raise ValueError("query obbligatoria")
params: dict[str, Any] = {"q": q, "limit": min(max(limit, 1), 50)}
if repo:
owner, name = parse_repo(repo)
params["repo"] = f"{owner}/{name}"
try:
data = _request("GET", "/search/code", username=username, params=params)
hits = []
for item in data.get("data") or []:
repo_name = item.get("repository", {}).get("full_name") or item.get("repository", {}).get("name")
hits.append(
{
"repo": repo_name,
"path": item.get("path"),
"sha": item.get("sha"),
"html_url": item.get("url") or item.get("html_url"),
"language": item.get("language"),
"snippet": (item.get("content") or item.get("text") or "")[:500],
}
)
return {"query": q, "repo": repo, "results": hits, "count": len(hits)}
except FileNotFoundError:
return _search_code_fallback(q, repo, limit, username)
except httpx.HTTPStatusError as exc:
if exc.response.status_code not in (404, 422):
raise
return _search_code_fallback(q, repo, limit, username)
def _search_code_fallback(
query: str,
repo: Optional[str],
limit: int,
username: Optional[str],
) -> dict:
"""Fallback se /search/code non disponibile: tree + grep su file testo."""
repos: list[str] = []
if repo:
owner, name = parse_repo(repo)
repos.append(f"{owner}/{name}")
else:
listed = list_repos(username=username, limit=20)
repos = [r["full_name"] for r in listed["repos"] if r.get("full_name")]
terms = [t.lower() for t in re.split(r"\s+", query) if t]
hits: list[dict] = []
max_files = min(limit * 3, 40)
for full_name in repos:
owner, name = parse_repo(full_name)
try:
tree = _request(
"GET",
f"/repos/{owner}/{name}/git/trees/HEAD",
username=username,
params={"recursive": "1"},
)
except Exception:
continue
scanned = 0
for node in tree.get("tree") or []:
if node.get("type") != "blob":
continue
path = node.get("path") or ""
ext = os.path.splitext(path)[1].lower()
if ext and ext not in TEXT_EXTENSIONS:
continue
if any(term in path.lower() for term in terms):
pass
scanned += 1
if scanned > max_files:
break
try:
file_data = get_file(full_name, path, username=username)
except Exception:
continue
content = (file_data.get("content") or "").lower()
if not any(term in content or term in path.lower() for term in terms):
continue
snippet = file_data.get("content") or ""
idx = snippet.lower().find(terms[0]) if terms else 0
if idx < 0:
idx = 0
hits.append(
{
"repo": full_name,
"path": path,
"sha": node.get("sha"),
"html_url": file_data.get("html_url"),
"snippet": snippet[max(0, idx - 80): idx + 420],
}
)
if len(hits) >= limit:
break
if len(hits) >= limit:
break
return {"query": query, "repo": repo, "results": hits[:limit], "count": len(hits[:limit]), "mode": "fallback"}
def list_issues(
repo: str,
state: str = "open",
page: int = 1,
limit: int = 20,
username: Optional[str] = None,
) -> dict:
owner, name = parse_repo(repo)
data = _request(
"GET",
f"/repos/{owner}/{name}/issues",
username=username,
params={"state": state, "page": page, "limit": limit, "type": "issues"},
)
issues = []
for item in data if isinstance(data, list) else []:
issues.append(
{
"number": item.get("number"),
"title": item.get("title"),
"state": item.get("state"),
"user": (item.get("user") or {}).get("login"),
"html_url": item.get("html_url"),
"created_at": item.get("created_at"),
"updated_at": item.get("updated_at"),
"labels": [lbl.get("name") for lbl in (item.get("labels") or [])],
}
)
return {"repo": f"{owner}/{name}", "state": state, "issues": issues, "count": len(issues)}
def get_issue(
repo: str,
number: int,
username: Optional[str] = None,
) -> dict:
owner, name = parse_repo(repo)
item = _request("GET", f"/repos/{owner}/{name}/issues/{number}", username=username)
return {
"repo": f"{owner}/{name}",
"number": item.get("number"),
"title": item.get("title"),
"state": item.get("state"),
"body": item.get("body") or "",
"user": (item.get("user") or {}).get("login"),
"html_url": item.get("html_url"),
"created_at": item.get("created_at"),
"updated_at": item.get("updated_at"),
"labels": [lbl.get("name") for lbl in (item.get("labels") or [])],
}
def create_issue(
repo: str,
title: str,
body: str = "",
labels: Optional[list[str]] = None,
username: Optional[str] = None,
) -> dict:
owner, name = parse_repo(repo)
payload: dict[str, Any] = {"title": title.strip(), "body": body or ""}
if labels:
payload["labels"] = labels
item = _request(
"POST",
f"/repos/{owner}/{name}/issues",
username=username,
json_body=payload,
)
return {
"repo": f"{owner}/{name}",
"number": item.get("number"),
"title": item.get("title"),
"state": item.get("state"),
"html_url": item.get("html_url"),
}
def assert_repo_owner(username: str, repo: str, *, is_admin: bool = False) -> tuple[str, str]:
owner, name = parse_repo(repo)
if not is_admin and owner.lower() != username.lower():
raise PermissionError(
f"Puoi scrivere solo su repository di cui sei owner (repo {owner}/{name}, utente {username})"
)
return owner, name
def create_repo(
name: str,
username: Optional[str] = None,
*,
private: bool = True,
description: str = "",
auto_init: bool = True,
) -> dict:
repo_name = name.strip().lower()
if not repo_name or not re.match(r"^[a-z0-9][a-z0-9._-]{0,99}$", repo_name):
raise ValueError("name repo non valido (usa lettere minuscole, numeri, -, _, .)")
payload: dict[str, Any] = {
"name": repo_name,
"private": private,
"auto_init": auto_init,
"description": description.strip(),
}
item = _request("POST", "/user/repos", username=username, json_body=payload)
full_name = item.get("full_name") or f"{username}/{repo_name}"
return {
"full_name": full_name,
"private": bool(item.get("private", private)),
"html_url": item.get("html_url") or f"{public_base_url()}/{full_name}",
"default_branch": item.get("default_branch") or "main",
"description": item.get("description") or description,
}
def create_or_update_file(
repo: str,
path: str,
content: str,
message: str,
*,
branch: Optional[str] = None,
username: Optional[str] = None,
) -> dict:
owner, repo_name = parse_repo(repo)
file_path = path.lstrip("/")
if not file_path:
raise ValueError("path obbligatorio")
if not message.strip():
raise ValueError("message commit obbligatorio")
encoded_path = "/".join(quote(part, safe="") for part in file_path.split("/"))
params = {}
if branch:
params["ref"] = branch
sha = None
action = "create"
try:
existing = get_file(repo, file_path, ref=branch, username=username)
if existing.get("type") == "file":
sha = existing.get("sha")
action = "update"
except FileNotFoundError:
pass
body: dict[str, Any] = {
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
"message": message.strip(),
}
if sha:
body["sha"] = sha
if branch:
body["branch"] = branch
method = "PUT" if sha else "POST"
item = _request(
method,
f"/repos/{owner}/{repo_name}/contents/{encoded_path}",
username=username,
params=params or None,
json_body=body,
)
commit = item.get("commit") or {}
content_obj = item.get("content") or {}
return {
"repo": f"{owner}/{repo_name}",
"path": file_path,
"action": action,
"branch": branch or "default",
"sha": content_obj.get("sha"),
"commit_sha": commit.get("sha"),
"html_url": content_obj.get("html_url")
or f"{public_base_url()}/{owner}/{repo_name}/src/branch/{branch or 'main'}/{file_path}",
}
@@ -0,0 +1,369 @@
# -*- coding: utf-8 -*-
"""Indicizzazione semantica (RAG) su file testo dei repository Gitea."""
from __future__ import annotations
import logging
import os
import re
import zlib
from typing import Optional
from ..db import get_conn
from . import embeddings, gitea, qdrant_store
from .text_chunk import CHUNK_OVERLAP, CHUNK_SIZE, chunk_text as _chunk_text
LOGGER = logging.getLogger("loogle_mcp.gitea_indexer")
SKIP_PATH_PARTS = (
"node_modules/",
"vendor/",
".git/",
"dist/",
"build/",
"__pycache__/",
".venv/",
"venv/",
".tox/",
"coverage/",
)
PRIORITY_PREFIXES = ("docs/", "ha/", "doc/", "README")
def _enabled() -> bool:
flag = os.environ.get("GITEA_INDEX_ENABLED", "yes").strip().lower()
return flag not in ("0", "false", "no", "off")
def _max_files_per_repo() -> int:
return int(os.environ.get("GITEA_INDEX_MAX_FILES_PER_REPO", "150"))
def _max_file_bytes() -> int:
return int(os.environ.get("GITEA_INDEX_MAX_FILE_BYTES", "120000"))
def _repos_for_user(username: str) -> list[str]:
env_key = f"GITEA_INDEX_REPOS_{username.upper()}"
raw = os.environ.get(env_key, "").strip()
if raw:
repos: list[str] = []
for item in raw.split(","):
item = item.strip()
if not item:
continue
owner, name = gitea.parse_repo(item)
repos.append(f"{owner}/{name}")
return repos
repos: list[str] = []
page = 1
while page <= 5:
data = gitea.list_repos(username=username, page=page, limit=50)
batch = [r["full_name"] for r in data.get("repos") or [] if r.get("full_name")]
if not batch:
break
repos.extend(batch)
if len(batch) < 50:
break
page += 1
return repos
def _should_index_path(path: str) -> bool:
lowered = path.lower()
if any(part in lowered for part in SKIP_PATH_PARTS):
return False
ext = os.path.splitext(path)[1].lower()
return bool(ext and ext in gitea.TEXT_EXTENSIONS)
def _path_priority(path: str) -> tuple[int, str]:
lowered = path.lower()
for idx, prefix in enumerate(PRIORITY_PREFIXES):
if lowered.startswith(prefix.lower()) or os.path.basename(lowered).startswith(prefix.lower()):
return (idx, path)
return (len(PRIORITY_PREFIXES), path)
def list_repo_text_files(repo: str, username: str) -> list[dict]:
owner, name = gitea.parse_repo(repo)
tree = gitea._request(
"GET",
f"/repos/{owner}/{name}/git/trees/HEAD",
username=username,
params={"recursive": "1"},
)
max_files = _max_files_per_repo()
max_bytes = _max_file_bytes()
files: list[dict] = []
for node in tree.get("tree") or []:
if node.get("type") != "blob":
continue
path = node.get("path") or ""
if not _should_index_path(path):
continue
size = int(node.get("size") or 0)
if size > max_bytes:
continue
files.append({"path": path, "sha": node.get("sha"), "size": size})
files.sort(key=lambda item: _path_priority(item["path"]))
return files[:max_files]
def _repo_owner(repo: str) -> str:
owner, _ = gitea.parse_repo(repo)
return owner.lower()
def _repo_visibility(private: bool, owner: str) -> str:
if private:
return "personal"
if owner in gitea.MCP_USERS:
return "family"
return "family"
def _collection_for_repo(private: bool, owner: str) -> str:
visibility = _repo_visibility(private, owner)
if visibility == "personal":
return qdrant_store.gitea_collection(owner)
return qdrant_store.GITEA_SHARED_COLLECTION
def _file_doc_id(repo: str, path: str) -> int:
return zlib.adler32(f"{repo}:{path}".encode("utf-8")) & 0x7FFFFFFF
def _file_title(repo: str, path: str) -> str:
return f"{repo}/{path}"
def index_file(
repo: str,
path: str,
*,
username: str,
private: bool,
force: bool = False,
) -> dict:
owner = _repo_owner(repo)
conn = get_conn()
row = conn.execute(
"SELECT sha, chunk_count FROM indexed_gitea_files WHERE repo=? AND path=?",
(repo, path),
).fetchone()
file_data = gitea.get_file(repo, path, username=username)
sha = file_data.get("sha") or ""
if row and row["sha"] == sha and not force:
return {"repo": repo, "path": path, "skipped": True, "sha": sha}
text = (file_data.get("content") or "").strip()
if not text:
return {"repo": repo, "path": path, "chunks": 0, "sha": sha}
header = f"# {_file_title(repo, path)}\n\nSource: gitea:{repo}:{path}\n\n"
chunks = _chunk_text(header + text)
if not chunks:
return {"repo": repo, "path": path, "chunks": 0, "sha": sha}
visibility = _repo_visibility(private, owner)
collection = _collection_for_repo(private, owner)
doc_id = _file_doc_id(repo, path)
qdrant_store.delete_by_doc(collection, doc_id)
vectors = embeddings.embed_texts(chunks)
ids = []
payloads = []
for i, chunk in enumerate(chunks):
point_id = f"gitea-{repo}-{path}-chunk-{i}"
ids.append(point_id)
payloads.append(
{
"source": "gitea",
"doc_id": doc_id,
"repo": repo,
"path": path,
"chunk_index": i,
"title": _file_title(repo, path),
"text": chunk,
"owner": owner,
"visibility": visibility,
"sha": sha,
}
)
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
stored = qdrant_store.count_by_doc(collection, doc_id)
if stored < len(chunks):
raise RuntimeError(
f"Qdrant upsert incompleto per {repo}/{path}: attesi {len(chunks)} chunk, trovati {stored}"
)
conn.execute(
"INSERT INTO indexed_gitea_files(repo,path,sha,owner,visibility,chunk_count,indexed_at)"
" VALUES (?,?,?,?,?,?,datetime('now'))"
" ON CONFLICT(repo,path) DO UPDATE SET"
" sha=excluded.sha, owner=excluded.owner, visibility=excluded.visibility,"
" chunk_count=excluded.chunk_count, indexed_at=datetime('now')",
(repo, path, sha, owner, visibility, len(chunks)),
)
conn.commit()
return {
"repo": repo,
"path": path,
"sha": sha,
"chunks": len(chunks),
"collection": collection,
"visibility": visibility,
}
def index_repo(
repo: str,
*,
username: Optional[str] = None,
private: Optional[bool] = None,
force: bool = False,
max_files: Optional[int] = None,
) -> dict:
gitea_user = username or _repo_owner(repo)
if not gitea.is_configured(gitea_user):
raise RuntimeError(f"Gitea non configurato per {gitea_user}")
if private is None:
owner, name = gitea.parse_repo(repo)
meta = gitea._request("GET", f"/repos/{owner}/{name}", username=gitea_user)
private = bool(meta.get("private"))
files = list_repo_text_files(repo, gitea_user)
if max_files is not None:
files = files[: max(1, max_files)]
indexed = 0
skipped = 0
errors = 0
chunks = 0
for item in files:
try:
result = index_file(
repo,
item["path"],
username=gitea_user,
private=bool(private),
force=force,
)
if result.get("skipped"):
skipped += 1
else:
indexed += 1
chunks += int(result.get("chunks") or 0)
except Exception as exc:
LOGGER.warning("Index gitea %s/%s failed: %s", repo, item.get("path"), exc)
errors += 1
return {
"repo": repo,
"files_seen": len(files),
"files_indexed": indexed,
"files_skipped": skipped,
"errors": errors,
"chunks": chunks,
"private": bool(private),
}
def index_all(max_files_per_repo: Optional[int] = None) -> dict:
if not _enabled():
return {"enabled": False, "indexed_files": 0}
total_indexed = 0
total_skipped = 0
total_errors = 0
total_chunks = 0
repos_done: list[str] = []
users = gitea.list_configured_users() or []
for username in users:
try:
repos = _repos_for_user(username)
except Exception as exc:
LOGGER.error("Lista repo Gitea fallita per %s: %s", username, exc)
continue
for repo in repos:
try:
result = index_repo(
repo,
username=username,
force=False,
max_files=max_files_per_repo,
)
repos_done.append(repo)
total_indexed += int(result.get("files_indexed") or 0)
total_skipped += int(result.get("files_skipped") or 0)
total_errors += int(result.get("errors") or 0)
total_chunks += int(result.get("chunks") or 0)
except Exception as exc:
LOGGER.warning("Index repo %s failed (%s): %s", repo, username, exc)
total_errors += 1
return {
"enabled": True,
"repos": repos_done,
"files_indexed": total_indexed,
"files_skipped": total_skipped,
"errors": total_errors,
"chunks": total_chunks,
"users": users,
}
def gitea_collections_for_user(username: str, is_admin: bool = False) -> list[str]:
cols = [qdrant_store.GITEA_SHARED_COLLECTION, qdrant_store.gitea_collection(username)]
if is_admin:
for user in gitea.MCP_USERS:
cols.append(qdrant_store.gitea_collection(user))
return list(dict.fromkeys(cols))
def search_gitea_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list:
vectors = embeddings.embed_texts([query])
collections = gitea_collections_for_user(username, is_admin=is_admin)
hits = qdrant_store.search(collections, vectors[0], limit=limit)
for hit in hits:
hit["source"] = hit.get("source") or "gitea"
return hits
def list_indexed_files(limit: int = 30, repo: Optional[str] = None) -> list:
conn = get_conn()
if repo:
rows = conn.execute(
"SELECT * FROM indexed_gitea_files WHERE repo=? ORDER BY indexed_at DESC LIMIT ?",
(repo, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM indexed_gitea_files ORDER BY indexed_at DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
def index_stats() -> dict:
conn = get_conn()
row = conn.execute(
"SELECT COUNT(*), COALESCE(SUM(chunk_count), 0) FROM indexed_gitea_files"
).fetchone()
files_count = int(row[0] if row else 0)
chunks_meta = int(row[1] if row else 0)
collections = {qdrant_store.GITEA_SHARED_COLLECTION}
for user in gitea.MCP_USERS:
collections.add(qdrant_store.gitea_collection(user))
qdrant_points = sum(qdrant_store.collection_point_count(c) for c in collections)
return {
"files_indexed": files_count,
"chunks_in_metadata": chunks_meta,
"qdrant_points": qdrant_points,
"collections": {c: qdrant_store.collection_point_count(c) for c in sorted(collections)},
}
@@ -0,0 +1,156 @@
# -*- 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) -> 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}"
qdrant_store.delete_by_doc(collection, hash(doc_key) % (2**31))
ids = []
payloads = []
for i, chunk in enumerate(chunks):
point_id = f"{doc_key}-chunk-{i}"
ids.append(point_id)
payloads.append(
{
"doc_id": hash(doc_key) % (2**31),
"project_id": project_id,
"chunk_index": i,
"title": title,
"text": chunk,
"owner": username,
"visibility": "personal",
}
)
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