tuts-rag-service / core /utils.py
Gil Almeida
feat: Implement intent router early return and fix material names null checks
a44a8a4
Raw
History Blame Contribute Delete
14.9 kB
import re
import unicodedata
import logging
import datetime
import json
from pathlib import Path
# Reutilizar o logger configurado do sistema
logger = logging.getLogger("tuts")
def limpar_nome_uc(uc: str) -> str:
"""
Normaliza o nome da UC para um formato seguro no sistema de ficheiros.
Remove acentos, carateres especiais e limita o tamanho rigidamente.
"""
sem_acentos = unicodedata.normalize("NFKD", uc)
sem_acentos = sem_acentos.encode("ascii", "ignore").decode("ascii")
limpo = "".join(x if x.isalnum() or x == "_" else "_" for x in sem_acentos)
# Limite de 80 caracteres para prevenir erros no File System (Path Too Long)
return re.sub(r"_+", "_", limpo).strip("_").lower()[:80]
def _base_faiss_dir() -> Path:
from config import settings
return Path(settings.base_faiss_dir)
def pasta_faiss_canonica_uc(uc: str) -> Path:
"""
Caminho canonico para novas pastas FAISS.
Novas ingestoes devem escrever sempre nesta pasta. Pastas antigas podem
continuar a ser lidas atraves de resolver_pasta_faiss_uc().
"""
return _base_faiss_dir() / limpar_nome_uc(uc)
def _manifest_updated_at(manifest: dict) -> datetime.datetime:
raw = str(manifest.get("updated_at") or "").strip()
if not raw:
return datetime.datetime.min.replace(tzinfo=datetime.timezone.utc)
try:
return datetime.datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return datetime.datetime.min.replace(tzinfo=datetime.timezone.utc)
def _manifest_name_matches(manifest: dict, nome_normalizado: str) -> bool:
for key in ("uc", "canonical_uc", "canonical_name", "name", "subject_name"):
value = manifest.get(key)
if value is not None and limpar_nome_uc(str(value)) == nome_normalizado:
return True
aliases = manifest.get("aliases")
if isinstance(aliases, list):
for alias in aliases:
if limpar_nome_uc(str(alias)) == nome_normalizado:
return True
return False
def _manifest_id_matches(manifest: dict, uc: str) -> bool:
m_context_id = manifest.get("context_id")
m_subject_id = manifest.get("subject_id")
return (
(m_context_id is not None and str(m_context_id) == str(uc))
or (m_subject_id is not None and str(m_subject_id) == str(uc))
)
def _read_manifest(path: Path) -> dict | None:
manifest_path = path / "manifest.json"
if not manifest_path.is_file():
return None
try:
with manifest_path.open("r", encoding="utf-8") as f:
manifest = json.load(f)
except Exception:
return None
return manifest if isinstance(manifest, dict) else None
def resolver_pasta_faiss_uc(uc: str, preferred_uc: str | None = None) -> Path:
"""
Resolve a pasta FAISS de uma UC com compatibilidade para nomes legacy.
A ordem e:
1. pasta canonica da UC preferida, quando fornecida;
2. pasta canonica;
3. primeira pasta existente em faiss_db cujo nome normalizado seja igual;
4. correspondência por context_id ou subject_id no manifest.json;
5. caminho canonico, mesmo que ainda nao exista.
Esta funcao nunca renomeia nem apaga pastas.
"""
nome_normalizado = limpar_nome_uc(uc)
caminho_canonico = _base_faiss_dir() / nome_normalizado
preferred_normalizado = limpar_nome_uc(preferred_uc or "")
caminho_preferido = _base_faiss_dir() / preferred_normalizado if preferred_normalizado else None
if caminho_preferido and caminho_preferido.exists():
if preferred_normalizado != nome_normalizado:
logger.info(
"[TUTS][RAG][IndexResolve] direct UC folder preferred over manifest id mapping | subject_id=%s | uc=%s | folder=%s",
uc,
preferred_normalizado,
caminho_preferido.name,
)
return caminho_preferido
if caminho_canonico.exists():
return caminho_canonico
base = _base_faiss_dir()
if base.exists():
# 1. Correspondência exata por nome normalizado da pasta
if preferred_normalizado:
for pasta in sorted(base.iterdir(), key=lambda p: p.name.lower()):
if pasta.is_dir() and limpar_nome_uc(pasta.name) == preferred_normalizado:
logger.info(
"[TUTS][RAG][IndexResolve] direct UC folder preferred over manifest id mapping | subject_id=%s | uc=%s | folder=%s",
uc,
preferred_normalizado,
pasta.name,
)
return pasta
for pasta in sorted(base.iterdir(), key=lambda p: p.name.lower()):
if pasta.is_dir() and limpar_nome_uc(pasta.name) == nome_normalizado:
logger.warning(
"A usar pasta FAISS legacy para UC '%s': %s",
uc,
pasta,
)
return pasta
# 2. Correspondência por ID da UC (context_id ou subject_id no manifest.json)
manifest_matches: list[tuple[Path, dict]] = []
for pasta in sorted(base.iterdir(), key=lambda p: p.name.lower()):
if pasta.is_dir():
manifest = _read_manifest(pasta)
if manifest is not None and _manifest_id_matches(manifest, uc):
manifest_matches.append((pasta, manifest))
if manifest_matches:
if preferred_normalizado:
preferred_matches = [
(pasta, manifest)
for pasta, manifest in manifest_matches
if limpar_nome_uc(pasta.name) == preferred_normalizado
or _manifest_name_matches(manifest, preferred_normalizado)
]
if preferred_matches:
pasta, manifest = max(
preferred_matches,
key=lambda item: _manifest_updated_at(item[1]),
)
logger.info(
"[TUTS][RAG][IndexResolve] direct UC folder preferred over manifest id mapping | subject_id=%s | uc=%s | folder=%s",
uc,
preferred_normalizado,
pasta.name,
)
return pasta
if len(manifest_matches) > 1:
logger.warning(
"[TUTS][RAG][IndexResolve] duplicate subject_id mapping detected | subject_id=%s | folders=%s",
uc,
[pasta.name for pasta, _ in manifest_matches],
)
pasta, manifest = max(
manifest_matches,
key=lambda item: _manifest_updated_at(item[1]),
)
logger.info(
"[TUTS][RAG][IndexResolve] Mapped ID '%s' to folder '%s' via manifest.json",
uc,
pasta.name,
)
return pasta
return caminho_canonico
def sanitizar_input(texto: str, max_chars: int = 4000) -> str:
"""
Prepara e limpa o texto inserido pelo aluno para prevenir injeções básicas
e limitar o consumo de recursos.
"""
# Remove Null Bytes que podem quebrar funções nativas de C no Python ou SQL
texto = (texto or "").replace("\x00", "")
# Remove as tags XML que usamos internamente para isolar a prompt no RAG
texto = re.sub(r"</?pergunta_aluno>", "", texto, flags=re.IGNORECASE)
texto = texto.strip()
# Truncamento de segurança para evitar Prompt Stuffing e DoS de Tokens
return texto[:max_chars]
def cosine_similarity(v1: list[float], v2: list[float]) -> float:
"""
Calcula a similaridade de cosseno de forma segura.
Valida as dimensões dos vetores antes de calcular para evitar exceções matemáticas.
"""
import numpy as np
a, b = np.asarray(v1, dtype=np.float32), np.asarray(v2, dtype=np.float32)
# Prevenção de ValueError no dot product
if a.shape != b.shape:
logger.warning(
"[UTILS] Erro Matemático Prevenido: Vetores com dimensões incompatíveis (%s vs %s)",
a.shape,
b.shape
)
return 0.0
denom = np.linalg.norm(a) * np.linalg.norm(b)
return float(np.dot(a, b) / denom) if denom > 0 else 0.0
def avaliar_suficiencia_contexto(pergunta: str, textos_chunks: list[str]) -> dict:
"""Avalia a suficiência do contexto recuperado para responder à pergunta."""
def normalizar_texto(valor: str) -> str:
sem_acentos = unicodedata.normalize("NFKD", valor or "")
ascii_texto = sem_acentos.encode("ascii", "ignore").decode("ascii").lower()
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9_]+", " ", ascii_texto)).strip()
tokens_originais = re.findall(r"\b[\w-]+\b", pergunta or "")
pergunta_norm = normalizar_texto(pergunta)
contexto_norm = normalizar_texto(" ".join(str(t) for t in (textos_chunks or [])))
if not pergunta_norm or not contexto_norm:
return {
"suficiente": False,
"razao": "pergunta_ou_contexto_vazio",
"cobertura": 0.0,
"termos": [],
"marcadores_presentes": [],
"marcadores_ausentes": []
}
def correspondem(w1: str, w2: str) -> bool:
if w1 == w2:
return True
# Plural simples
if w1 + "s" == w2 or w2 + "s" == w1:
return True
if w1 + "es" == w2 or w2 + "es" == w1:
return True
# Plurais especiais em Português (al/ais, el/eis, il/is, ol/ois, ul/uis)
for suffix_sing, suffix_plur in [("al", "ais"), ("el", "eis"), ("il", "is"), ("ol", "ois"), ("ul", "uis")]:
if w1.endswith(suffix_sing) and w2.endswith(suffix_plur) and w1[:-2] == w2[:-3]:
return True
if w2.endswith(suffix_sing) and w1.endswith(suffix_plur) and w2[:-2] == w1[:-3]:
return True
# Mapeamentos bilingues / técnicos comuns
pairs = [
("deploy", "deployment"),
("dominio", "domain"),
("aplicacao", "application"),
("modulo", "module"),
("ficheiro", "file"),
("signo", "sinais"),
("signo", "sinal"),
("signo", "signos"),
]
for p1, p2 in pairs:
if (w1 == p1 and w2 == p2) or (w1 == p2 and w2 == p1):
return True
# Substring prefix matching com bloqueio de falsos positivos
if len(w1) >= 4 and len(w2) >= 4:
if w1.startswith(w2) or w2.startswith(w1):
bad_pairs = [("objeto", "objetivo"), ("comunidade", "comunicacao")]
if not any((w1.startswith(b1) and w2.startswith(b2)) or (w1.startswith(b2) and w2.startswith(b1)) for b1, b2 in bad_pairs):
return True
return False
# 1. Detetar termos fora de âmbito
termos_fora_escopo = [
"deploy", "vercel", "netlify", "docker", "hosting", "hospedar",
"aws", "amplify", "stripe", "figma", "photoshop", "inpi", "patente",
"dns", "cpa", "roi", "ads", "google ads", "hostinger", "cloudflare", "mockup"
]
termos_suspeitos = [t for t in termos_fora_escopo if t in pergunta_norm]
if termos_suspeitos:
# Se nenhum dos termos suspeitos na pergunta tiver correspondência no contexto
if not any(any(correspondem(t, c) for c in contexto_norm.split()) for t in termos_suspeitos):
termos_pergunta = {t for t in pergunta_norm.split() if len(t) >= 4 or t in ["js", "css", "html", "dom", "ads", "roi", "cpa", "pc"]}
termos_contexto = {t for t in contexto_norm.split() if len(t) >= 3}
correspondencias = {termo for termo in termos_pergunta if any(correspondem(termo, candidato) for candidato in termos_contexto)}
cobertura = len(correspondencias) / max(1, len(termos_pergunta))
marcadores = {
normalizar_texto(token) for indice, token in enumerate(tokens_originais)
if len(token) >= 3 and (token.isupper() or any(ch.isupper() for ch in token[1:]) or (indice > 0 and token[0].isupper()))
}
presentes = sorted(m for m in marcadores if m and any(correspondem(m, c) for c in termos_contexto))
return {
"suficiente": False,
"razao": "fora_de_ambito",
"cobertura": round(cobertura, 4),
"termos": sorted(correspondencias),
"marcadores_presentes": presentes,
"marcadores_ausentes": sorted(marcadores - set(presentes)),
}
# 2. Análise lexical padrão com plurais/géneros e verbos operacionais
termos_pergunta = {t for t in pergunta_norm.split() if len(t) >= 4 or t in ["js", "css", "html", "dom", "ads", "roi", "cpa", "pc"]}
termos_contexto = {t for t in contexto_norm.split() if len(t) >= 3}
correspondencias = {termo for termo in termos_pergunta if any(correspondem(termo, candidato) for candidato in termos_contexto)}
cobertura = len(correspondencias) / max(1, len(termos_pergunta))
marcadores = {
normalizar_texto(token) for indice, token in enumerate(tokens_originais)
if len(token) >= 3 and (token.isupper() or any(ch.isupper() for ch in token[1:]) or (indice > 0 and token[0].isupper()))
}
presentes = sorted(m for m in marcadores if m and any(correspondem(m, c) for c in termos_contexto))
marcadores_ausentes = sorted(marcadores - set(presentes))
# Verificar verbos operacionais
operacionais = set(re.findall(r"\b(?:configur\w*|cri\w*|regist\w*|calcul\w*|anim\w*|export\w*|instal\w*|execut\w*|public\w*|edit\w*)\b", pergunta_norm))
operacao_suportada = not operacionais or any(any(op[:4] in c for c in termos_contexto) for op in operacionais)
entidade_especifica_sem_evidencia = bool(marcadores) and not presentes
cobertura_minima = 0.25 if (presentes and marcadores_ausentes) else 0.15
suficiente = cobertura >= cobertura_minima and not entidade_especifica_sem_evidencia and operacao_suportada
return {
"suficiente": suficiente,
"razao": "evidencia_recuperada_suficiente" if suficiente else ("entidade_especifica_ausente_no_contexto" if entidade_especifica_sem_evidencia else "cobertura_conceitual_insuficiente"),
"cobertura": round(cobertura, 4),
"termos": sorted(correspondencias),
"marcadores_presentes": presentes,
"marcadores_ausentes": marcadores_ausentes,
}
def verificar_necessidade_recusa_fora_escopo(pergunta: str, textos_chunks: list[str]) -> bool:
return not avaliar_suficiencia_contexto(pergunta, textos_chunks)["suficiente"]