Backup automatico script del 2026-08-23 12:04
This commit is contained in:
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"])]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user