Funcional

This commit is contained in:
2026-05-01 14:12:53 -03:00
parent a170a09cbd
commit 171e0bb3af
52 changed files with 1727 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from .factory import create_rails, get_config_path
__all__ = ["create_rails", "get_config_path"]

View File

@@ -0,0 +1,284 @@
from typing import Optional
from nemoguardrails.actions import action
from .deterministic_rails import (
mask_pii,
validar_alcada,
enforce_compliance_anatel,
calcular_tcr,
detectar_fallback,
registrar_violacao,
validar_consistencia_historica,
contabilizar_tokens,
calcular_eficiencia_nlu,
detectar_no_match_rag,
detectar_loop,
medir_tamanho_mensagem,
calcular_precisao_revocacao,
avaliar_acuracia_semantica,
)
from .llm_rails import (
detectar_toxicidade,
detectar_out_of_scope,
verbalizacao_prematura,
validar_groundedness,
supervisor_vas_avulso,
)
# =========================
# HELPERS
# =========================
def get_payload(context: Optional[dict]) -> dict:
return (context or {}).get("payload", {})
# =========================
# ACTIONS
# =========================
@action(is_system_action=True)
async def mask_pii_action(context: Optional[dict] = None, **kwargs):
print("🔥 MSK")
payload = get_payload(context)
input_text = payload.get("input_text") or context.get("user_message", "")
result = mask_pii(input_text)
if context is not None:
context["text"] = getattr(result, "sanitized_text", input_text)
return result
# -------------------------
@action(is_system_action=True)
async def detectar_toxicidade_action(context: Optional[dict] = None, **kwargs):
print("🔥 TOX")
text = context.get("text") or context.get("user_message", "")
result = detectar_toxicidade(text)
return result
# -------------------------
@action(is_system_action=True)
async def detectar_out_of_scope_action(context: Optional[dict] = None, **kwargs):
print("🔥 OOS")
text = context.get("text") or context.get("user_message", "")
result = detectar_out_of_scope(text)
return result
# -------------------------
@action(is_system_action=True)
async def validar_alcada_action(context: Optional[dict] = None, **kwargs):
print("🔥 ADJ")
payload = get_payload(context)
ctx = payload.get("context", {})
valor = ctx.get("ajuste_valor", 0)
result = validar_alcada(valor)
return result
# -------------------------
@action(is_system_action=True)
async def verbalizacao_prematura_action(context: Optional[dict] = None, **kwargs):
print("🔥 REVPREC")
payload = get_payload(context)
ctx = payload.get("context", {})
resposta = ctx.get("resposta_llm", "")
result = verbalizacao_prematura(resposta, ctx)
return result
# -------------------------
@action(is_system_action=True)
async def validar_groundedness_action(context: Optional[dict] = None, **kwargs):
print("🔥 GND")
payload = get_payload(context)
ctx = payload.get("context", {})
resposta = ctx.get("resposta_llm", "")
result = validar_groundedness(resposta, ctx)
return result
# -------------------------
@action(is_system_action=True)
async def supervisor_vas_avulso_action(context: Optional[dict] = None, **kwargs):
print("🔥 REVPREC_SUP")
payload = get_payload(context)
result = supervisor_vas_avulso(payload)
return result
@action(is_system_action=True)
async def enforce_compliance_anatel_action(context=None, **kwargs):
print("🔥 CMP")
text = context.get("text") or context.get("user_message", "")
payload = get_payload(context)
ctx = payload.get("context", {})
result = enforce_compliance_anatel(text, ctx)
return result
@action(is_system_action=True)
async def calcular_tcr_action(context=None, **kwargs):
print("🔥 TCR")
payload = get_payload(context)
status = payload.get("context", {}).get("status", "")
result = calcular_tcr(status)
return result
@action(is_system_action=True)
async def detectar_fallback_action(context=None, **kwargs):
print("🔥 FALLBACK")
text = context.get("text") or context.get("user_message", "")
result = detectar_fallback(text)
return result
@action(is_system_action=True)
async def registrar_violacao_action(context=None, **kwargs):
print("🔥 VIOL")
payload = get_payload(context)
agent_id = payload.get("agent_id", "unknown")
code = payload.get("violation_code", "UNKNOWN")
result = registrar_violacao(agent_id, code)
return result
@action(is_system_action=True)
async def validar_consistencia_historica_action(context=None, **kwargs):
print("🔥 HIST")
payload = get_payload(context)
ctx = payload.get("context", {})
result = validar_consistencia_historica(ctx)
return result
@action(is_system_action=True)
async def contabilizar_tokens_action(context=None, **kwargs):
print("🔥 PMPTK")
payload = get_payload(context)
prompt = payload.get("prompt_tokens", 0)
completion = payload.get("completion_tokens", 0)
result = contabilizar_tokens(prompt, completion)
return result
@action(is_system_action=True)
async def calcular_eficiencia_nlu_action(context=None, **kwargs):
print("🔥 EFIC")
payload = get_payload(context)
ctx = payload.get("context", {})
result = calcular_eficiencia_nlu(
ctx.get("chunks_retornados", 0),
ctx.get("chunks_utilizados", 0)
)
return result
@action(is_system_action=True)
async def detectar_no_match_rag_action(context=None, **kwargs):
print("🔥 NO-M")
payload = get_payload(context)
ctx = payload.get("context", {})
result = detectar_no_match_rag(
ctx.get("chunks", []),
ctx.get("resposta_llm", "")
)
return result
@action(is_system_action=True)
async def detectar_loop_action(context=None, **kwargs):
print("🔥 VLOOP")
payload = get_payload(context)
mensagens = payload.get("context", {}).get("mensagens", [])
result = detectar_loop(mensagens)
return result
@action(is_system_action=True)
async def medir_tamanho_mensagem_action(context=None, **kwargs):
print("🔥 MSIZE")
text = context.get("text") or context.get("user_message", "")
result = medir_tamanho_mensagem(text)
return result
@action(is_system_action=True)
async def calcular_precisao_revocacao_action(context=None, **kwargs):
print("🔥 REVPREC_METRIC")
payload = get_payload(context)
ctx = payload.get("context", {})
result = calcular_precisao_revocacao(
ctx.get("y_true", []),
ctx.get("y_pred", [])
)
return result
@action(is_system_action=True)
async def avaliar_acuracia_semantica_action(context=None, **kwargs):
print("🔥 SEMAC")
payload = get_payload(context)
ctx = payload.get("context", {})
result = avaliar_acuracia_semantica(
ctx.get("audio_transcrito", ""),
ctx.get("referencia_humana", "")
)
return result

View File

@@ -0,0 +1,178 @@
from .deterministic_rails import mask_pii, validar_alcada, enforce_compliance_anatel
from .llm_rails import (
detectar_toxicidade,
detectar_out_of_scope,
validar_groundedness,
verbalizacao_prematura,
supervisor_vas_avulso
)
from .judges import avaliar_qualidade_resposta
import json
def executar_atendimento(user_input: str, context: dict):
steps = []
# =========================
# 🔹 INPUT RAILS
# =========================
r = mask_pii(user_input)
steps.append(r)
text = r.sanitized_text or user_input
for rail in [detectar_toxicidade, detectar_out_of_scope]:
r = rail(text)
steps.append(r)
if not r.allowed:
return {
"allowed": False,
"blocked_by": r.code,
"response": None,
"steps": steps
}
# =========================
# 🔹 PYTHON RULE (CRÍTICA)
# =========================
if "ajuste_valor" in context:
r = validar_alcada(context["ajuste_valor"])
steps.append(r)
if not r.allowed:
return {
"allowed": False,
"blocked_by": r.code,
"response": None,
"steps": steps
}
# =========================
# 🔹 LLM RESPONSE
# =========================
resposta = context.get(
"resposta_llm",
"Resposta simulada do agente."
)
# =========================
# 🔹 OUTPUT RAILS (BLOQUEANTES)
# =========================
output_rails = [
enforce_compliance_anatel(resposta, context),
verbalizacao_prematura(resposta, context),
validar_groundedness(resposta, context),
]
for r in output_rails:
steps.append(r)
# 🔥 NÃO bloquear groundedness automaticamente
if not r.allowed and r.code != "GND":
return {
"allowed": False,
"blocked_by": r.code,
"response": None,
"steps": steps
}
# =========================
# 🔹 JUDGE (NÃO BLOQUEIA)
# =========================
r_quality = avaliar_qualidade_resposta(user_input, resposta)
steps.append(r_quality)
# =========================
# 🔹 SUPERVISOR (AUDITORIA)
# =========================
r_supervisor = supervisor_vas_avulso(
context.get("supervisor_payload", {})
)
steps.append(r_supervisor)
# =========================
# 🔹 RESULTADO FINAL
# =========================
BLOCKING_CODES = {"CMP", "ADJ", "REVPREC"}
allowed = all(
s.allowed for s in steps
if s.code in BLOCKING_CODES
)
return {
"allowed": allowed,
"response": resposta,
"steps": steps
}
# =========================
# 🔥 PRINT FORMATADO
# =========================
def print_result(result):
print("\n" + "=" * 80)
print("📊 RESULTADO FINAL")
print("=" * 80)
print(f"✔ Allowed: {result['allowed']}")
print(f"💬 Response: {result.get('response')}")
if not result["allowed"]:
print(f"🚫 Bloqueado por: {result.get('blocked_by')}")
print("\n🔎 STEPS:")
for s in result["steps"]:
print("-" * 60)
print(f"🧩 Code: {s.code}")
print(f"⚙️ Mechanism: {s.mechanism}")
print(f"✔ Allowed: {s.allowed}")
print(f"📝 Reason: {s.reason}")
if s.sanitized_text:
print(f"🔐 Sanitized: {s.sanitized_text}")
if s.data:
print(f"📦 Data: {s.data}")
print("=" * 80)
print("\n📦 JSON OUTPUT:")
print(json.dumps({
"allowed": result["allowed"],
"response": result.get("response"),
"steps": [
{
"code": s.code,
"allowed": s.allowed,
"reason": s.reason,
"mechanism": s.mechanism,
"data": s.data
}
for s in result["steps"]
]
}, indent=2, ensure_ascii=False))
# =========================
# 🔥 EXECUÇÃO DIRETA
# =========================
if __name__ == "__main__":
user_input = "Meu CPF é 123.456.789-00 e quero ajuste de 20 reais"
context = {
"ajuste_valor": 20,
"ajuste_validado": True,
"tipo_fluxo": "ajuste",
"requer_protocolo": True,
"resposta_llm": "Ajuste realizado. Protocolo: 202604270001.",
"chunks_rag": ["serviço fatura cobrança ajuste realizado protocolo"],
"supervisor_payload": {
"cancelamento_correto": True,
"servico_cancelado": "VAS Avulso",
"servico_solicitado": "VAS Avulso"
}
}
result = executar_atendimento(user_input, context)
print_result(result)

View File

@@ -0,0 +1,47 @@
#https://docs.nvidia.com/nemo/guardrails/latest/configure-rails/actions/index.html
#https://docs.nvidia.com/nemo/guardrails/latest/configure-rails/actions/registering-actions.html
#https://docs.nvidia.com/nemo/guardrails/latest/observability/logging/index.html
from nemoguardrails import LLMRails, RailsConfig
config = RailsConfig.from_path("./config")
rails = LLMRails(config)
def extract_return_values(response):
results = []
log = response.log
for rail in log.activated_rails:
for action in rail.executed_actions:
rv = action.return_value
if rv is not None:
results.append({
"action": action.action_name,
"allowed": getattr(rv, "allowed", None),
"reason": getattr(rv, "reason", None),
"sanitized_text": getattr(rv, "sanitized_text", None),
"code": getattr(rv, "code", None),
"mechanism": getattr(rv, "mechanism", None),
"data": getattr(rv, "data", None)
})
return results
MESSAGE = "Meu CPF é 169.323.728-86"
response = rails.generate(
messages=[{"role": "user", "content": MESSAGE}],
options={
"output_vars": ["triggered_input_rail", "relevant_chunks"],
"log": {
"activated_rails": True,
"llm_calls": True
}
}
)
feedback = extract_return_values(response)
for f in feedback:
print(f)

View File

@@ -0,0 +1,43 @@
from nemoguardrails import LLMRails, RailsConfig
from company_nemo_guardrails.actions import (
mask_pii_action,
detectar_toxicidade_action,
detectar_out_of_scope_action,
validar_alcada_action,
verbalizacao_prematura_action,
validar_groundedness_action,
supervisor_vas_avulso_action,
enforce_compliance_anatel_action,
calcular_tcr_action,
detectar_fallback_action,
registrar_violacao_action,
validar_consistencia_historica_action,
contabilizar_tokens_action,
calcular_eficiencia_nlu_action,
calcular_eficiencia_nlu_action,
detectar_loop_action,
medir_tamanho_mensagem_action,
calcular_precisao_revocacao_action,
avaliar_acuracia_semantica_action
)
def init(app: LLMRails):
app.register_action(mask_pii_action)
app.register_action(detectar_toxicidade_action)
app.register_action(detectar_out_of_scope_action)
app.register_action(validar_alcada_action)
app.register_action(verbalizacao_prematura_action)
app.register_action(validar_groundedness_action)
app.register_action(supervisor_vas_avulso_action)
app.register_action(enforce_compliance_anatel_action)
app.register_action(calcular_tcr_action)
app.register_action(detectar_fallback_action)
app.register_action(registrar_violacao_action)
app.register_action(validar_consistencia_historica_action)
app.register_action(contabilizar_tokens_action)
app.register_action(calcular_eficiencia_nlu_action)
app.register_action(calcular_eficiencia_nlu_action)
app.register_action(detectar_loop_action)
app.register_action(medir_tamanho_mensagem_action)
app.register_action(calcular_precisao_revocacao_action)
app.register_action(avaliar_acuracia_semantica_action)

View File

@@ -0,0 +1,27 @@
models:
- type: main
engine: openai
model: gpt-5
api_key_env_var: OPENAI_API_KEY
parameters:
temperature: 0
base_url: http://127.0.0.1:8051/v1
max_tokens: 50 # 🔥 evita respostas longas do LLM
# 🔥 usado apenas se você chamar explicitamente no flow
- type: self_check_input
engine: openai
model: openai.gpt-oss-120b
api_key_env_var: OPENAI_API_KEY
parameters:
temperature: 0
base_url: http://127.0.0.1:8051/v1
rails:
input:
flows:
- check_input_terms
output:
flows:
- check_output_terms

View File

@@ -0,0 +1,24 @@
guardrails:
- {codigo: MSK, item: "PII Masking", mecanismo: regex}
- {codigo: CMP, item: "Compliance Anatel", mecanismo: regex}
- {codigo: ADJ, item: "Alçada de Ajuste", mecanismo: python}
- {codigo: REVPREC_SUP, item: "Supervisor VAS Avulso", mecanismo: llm_supervisor}
- {codigo: TCR, item: "Conclusão de Tarefa", mecanismo: python}
- {codigo: REVPREC, item: "Verbalização Prematura", mecanismo: llm_rail}
- {codigo: FALLBACK, item: "Fallback Não Entendi", mecanismo: python}
- {codigo: VIOL, item: "Violação de Guardrails", mecanismo: python}
- {codigo: TOX, item: "Toxicidade", mecanismo: llm_rail}
- {codigo: OOS, item: "Out-of-Scope", mecanismo: llm_rail}
- {codigo: GND, item: "Groundedness", mecanismo: llm_rail}
- {codigo: HIST, item: "Consistência Histórica", mecanismo: python}
- {codigo: PMPTK, item: "Prompt Tokens", mecanismo: python}
- {codigo: EFIC, item: "Eficiência NLU", mecanismo: python}
- {codigo: NO-M, item: "No-Match RAG", mecanismo: python}
- {codigo: CSI, item: "Sentimento", mecanismo: llm_judge}
- {codigo: ALUC, item: "Taxa de Alucinação", mecanismo: llm_judge}
- {codigo: VLOOP, item: "Volume de Loops", mecanismo: python}
- {codigo: MSIZE, item: "Tamanho de Mensagem", mecanismo: python}
- {codigo: RQLT, item: "Qualidade da Resposta", mecanismo: llm_judge}
- {codigo: VCTN, item: "Tom de Voz", mecanismo: llm_judge}
- {codigo: REVPREC_METRIC, item: "Precisão e Revocação", mecanismo: python}
- {codigo: SEMAC, item: "Acurácia Semântica STT", mecanismo: python}

View File

@@ -0,0 +1,28 @@
define bot refuse to respond
"I apologize, but I cannot provide that information."
define flow check_input_terms
# 🔐 Segurança
$ok_msk = execute mask_pii_action
$ok_tox = execute detectar_toxicidade_action
$ok_oos = execute detectar_out_of_scope_action
# 💰 Regras de negócio
$ok_adj = execute validar_alcada_action
# 🧠 Contexto
$ok_hist = execute validar_consistencia_historica_action
# 🔁 Conversação
$ok_loop = execute detectar_loop_action
$ok_size = execute medir_tamanho_mensagem_action
$ok_fbk = execute detectar_fallback_action
# 🚨 HARD BLOCK
if not ($ok_msk and $ok_tox and $ok_oos and $ok_adj and $ok_hist)
bot refuse to respond
stop
# ⚠️ SOFT SIGNALS (não bloqueiam)
# loop, tamanho e fallback são monitoramento

View File

@@ -0,0 +1,28 @@
define flow check_output_terms
# 🧠 Qualidade da resposta
$ok_revp = execute verbalizacao_prematura_action
$ok_gnd = execute validar_groundedness_action
$ok_sup = execute supervisor_vas_avulso_action
# 📡 Compliance
$ok_cmp = execute enforce_compliance_anatel_action
# 📊 Métricas
$ok_tcr = execute calcular_tcr_action
$ok_tok = execute contabilizar_tokens_action
$ok_efic = execute calcular_eficiencia_nlu_action
#$ok_nom = execute detectar_no_match_rag_action
$ok_prec = execute calcular_precisao_revocacao_action
$ok_sem = execute avaliar_acuracia_semantica_action
# 🚨 Auditoria
$ok_viol = execute registrar_violacao_action
# 🚨 HARD BLOCK (só qualidade crítica)
if not ($ok_cmp)
bot refuse to respond
stop
# ⚠️ SOFT METRICS (não bloqueiam)
# TCR, tokens, eficiência, precisão, STT etc

View File

@@ -0,0 +1,66 @@
import re
from collections import Counter
from .models import RailResult
from .tracing import span
def mask_pii(text:str)->RailResult:
with span('rail.MSK', mechanism='regex'):
original=text
text=re.sub(r'\b\d{3}\.\d{3}\.\d{3}-\d{2}\b','[CPF_MASCARADO]',text)
text=re.sub(r'\b\d{16}\b','[CARTAO_MASCARADO]',text)
text=re.sub(r'(?i)(senha\s*[:=]?\s*)\S+',r'\1[SENHA_MASCARADA]',text)
return RailResult(True,'PII mascarada' if text!=original else 'Nenhuma PII detectada',text,'MSK','regex')
def enforce_compliance_anatel(text:str, context:dict)->RailResult:
with span('rail.CMP', mechanism='regex'):
requer=context.get('tipo_fluxo')=='ajuste' or context.get('requer_protocolo') is True
if not requer: return RailResult(True,'Compliance Anatel não aplicável',text,'CMP','regex')
has_protocol=bool(re.search(r'(?i)\bprotocolo\b[:\s-]*\d{6,}',text))
if not has_protocol: return RailResult(False,'Resposta de ajuste sem número de protocolo',text,'CMP','regex')
return RailResult(True,'Resposta contém protocolo obrigatório',text,'CMP','regex')
def validar_alcada(valor:float, limite:float=50.0)->RailResult:
with span('rail.ADJ', mechanism='python'):
if valor>limite: return RailResult(False,f'Valor R$ {valor:.2f} excede alçada de R$ {limite:.2f}; escalar para ATH',code='ADJ',mechanism='python')
return RailResult(True,f'Valor R$ {valor:.2f} dentro da alçada',code='ADJ',mechanism='python')
def calcular_tcr(status:str)->RailResult:
status=status.lower(); categoria='Indefinido'
if status in ['concluido','concluído','resolvido']: categoria='Concluído'
elif status in ['abandonado','timeout','desistencia']: categoria='Abandonado'
elif status in ['escalado','ath','humano']: categoria='Escalado'
return RailResult(True,f'TCR classificado como {categoria}',code='TCR',mechanism='python',data={'categoria':categoria})
def detectar_fallback(text:str)->RailResult:
frases=['não entendi','não consegui entender','não tenho informação','não encontrei informação']; detected=any(f in text.lower() for f in frases)
return RailResult(True,'Fallback detectado' if detected else 'Fallback não detectado',text,'FALLBACK','python',{'fallback':detected})
def registrar_violacao(agent_id:str, code:str)->RailResult:
return RailResult(True,'Violação registrada para agregação',code='VIOL',mechanism='python',data={'agent_id':agent_id,'violation_code':code,'count':1})
def validar_consistencia_historica(context:dict)->RailResult:
if context.get('contestacao_anterior')=='procedente_confirmada': return RailResult(False,'Fatura já confirmada como procedente anteriormente',code='HIST',mechanism='python')
return RailResult(True,'Sem conflito histórico',code='HIST',mechanism='python')
def contabilizar_tokens(prompt_tokens:int, completion_tokens:int)->RailResult:
total=prompt_tokens+completion_tokens; return RailResult(True,'Tokens contabilizados',code='PMPTK',mechanism='python',data={'prompt_tokens':prompt_tokens,'completion_tokens':completion_tokens,'total_tokens':total})
def calcular_eficiencia_nlu(chunks_retornados:int, chunks_utilizados:int)->RailResult:
eficiencia=chunks_utilizados/chunks_retornados if chunks_retornados else 0; return RailResult(True,'Eficiência NLU calculada',code='EFIC',mechanism='python',data={'eficiencia':eficiencia})
def detectar_no_match_rag(chunks:list, resposta:str)->RailResult:
no_match=not chunks or 'não encontrei' in resposta.lower(); return RailResult(True,'No-Match RAG detectado' if no_match else 'RAG retornou evidência útil',code='NO-M',mechanism='python',data={'no_match':no_match})
def detectar_loop(mensagens:list[str])->RailResult:
counts=Counter(mensagens); loop=any(v>=2 for v in counts.values()); return RailResult(True,'Loop detectado' if loop else 'Sem loop',code='VLOOP',mechanism='python',data={'loop':loop})
def medir_tamanho_mensagem(text:str)->RailResult:
return RailResult(True,'Tamanho de mensagem medido',text,'MSIZE','python',{'chars':len(text)})
def calcular_precisao_revocacao(y_true:list[str], y_pred:list[str])->RailResult:
total=len(y_true); correct=sum(1 for a,b in zip(y_true,y_pred) if a==b); accuracy=correct/total if total else 0
return RailResult(True,'Acurácia de roteamento calculada',code='REVPREC_METRIC',mechanism='python',data={'accuracy':accuracy})
def avaliar_acuracia_semantica(audio_transcrito:str, referencia_humana:str)->RailResult:
a=set(audio_transcrito.lower().split()); b=set(referencia_humana.lower().split()); score=len(a & b)/len(b) if b else 0
return RailResult(score>=0.85,f'Acurácia semântica STT: {score:.2f}',code='SEMAC',mechanism='python',data={'score':score})

View File

@@ -0,0 +1,14 @@
# Proxy OpenAI-compatible, por exemplo seu proxy OCI
OPENAI_API_BASE=http://127.0.0.1:8051/v1
OPENAI_BASE_URL=http://127.0.0.1:8051/v1
OPENAI_API_KEY=dummy
OPENAI_MODEL=gpt-5
# Tracing / Phoenix / OpenTelemetry
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:6006/v1/traces
OTEL_SERVICE_NAME=nemo-guardrails-demo
ENABLE_TRACING=true
# Modo demo: usa cliente fake se o proxy não estiver disponível
USE_MOCK_LLM=false
ALCADA_MAX_AJUSTE=50

View File

@@ -0,0 +1,18 @@
from importlib.resources import files
from nemoguardrails import LLMRails, RailsConfig
def get_config_path():
return str(files("company_nemo_guardrails").joinpath("config"))
def create_rails():
# 👇 IMPORT CRÍTICO (executa decorators @action)
from company_nemo_guardrails import actions
config = RailsConfig.from_path(get_config_path())
rails = LLMRails(config)
# 👇 opcional mas recomendado (garante registro)
rails.register_actions(actions)
return rails

View File

@@ -0,0 +1,16 @@
from .models import RailResult
from .llm_client import LLMClient
from .tracing import span
_client=LLMClient()
def classificar_sentimento(text:str)->RailResult:
with span("judge.CSI", mechanism="llm_judge"):
out=_client.classify("CSI", {"text":text}); return RailResult(True,out.get("reason",""),text,"CSI","llm_judge",{"sentimento":out.get("label"),**out})
def avaliar_alucinacao(resposta:str, dados_reais:str)->RailResult:
with span("judge.ALUC", mechanism="llm_judge"):
out=_client.classify("ALUC", {"resposta":resposta,"dados_reais":dados_reais}); return RailResult(out["allowed"],out.get("reason",""),resposta,"ALUC","llm_judge",{"alucinacao":out.get("label")=="ALUCINACAO",**out})
def avaliar_qualidade_resposta(pergunta:str, resposta:str)->RailResult:
with span("judge.RQLT", mechanism="llm_judge"):
out=_client.classify("RQLT", {"pergunta":pergunta,"resposta":resposta}); return RailResult(True,out.get("reason",""),resposta,"RQLT","llm_judge",out)
def avaliar_tom_de_voz(text:str)->RailResult:
with span("judge.VCTN", mechanism="llm_judge"):
out=_client.classify("VCTN", {"text":text}); return RailResult(out["allowed"],out.get("reason",""),text,"VCTN","llm_judge",{"aderente":out["allowed"],**out})

View File

@@ -0,0 +1,106 @@
import os, json
from openai import OpenAI
from company_nemo_guardrails.prompts.revprec import build_revprec_prompt
from company_nemo_guardrails.prompts.csi import build_csi_prompt
from company_nemo_guardrails.prompts.vctn import build_vctn_prompt
from company_nemo_guardrails.prompts.tox import build_tox_prompt
from company_nemo_guardrails.prompts.oos import build_oos_prompt
from company_nemo_guardrails.prompts.gnd import build_gnd_prompt
from company_nemo_guardrails.prompts.aluc import build_aluc_prompt
from company_nemo_guardrails.prompts.rqlt import build_rqlt_prompt
from company_nemo_guardrails.prompts.supervisor import build_supervisor_prompt
class LLMClient:
def __init__(self):
self.use_mock=os.getenv('USE_MOCK_LLM','true').lower()=='true'
self.model=os.getenv('OPENAI_MODEL','gpt-5')
self.client=None if self.use_mock else OpenAI(base_url=os.getenv('OPENAI_BASE_URL','http://localhost:8051/v1'), api_key=os.getenv('OPENAI_API_KEY','dummy'))
def classify(self, task, payload):
if self.use_mock:
return self._mock_classify(task, payload)
# ========================
# ROUTING DE PROMPTS
# ========================
if task == "REVPREC":
prompt = build_revprec_prompt(payload["text"], payload.get("context", {}))
elif task == "CSI":
prompt = build_csi_prompt(payload["text"])
elif task == "VCTN":
prompt = build_vctn_prompt(payload["text"])
elif task == "TOX":
prompt = build_tox_prompt(payload["text"])
elif task == "OOS":
prompt = build_oos_prompt(payload["text"])
elif task == "GND":
prompt = build_gnd_prompt(payload["resposta"], payload.get("context", {}))
# ========================
# 🔥 NOVOS (faltavam)
# ========================
elif task == "ALUC":
prompt = build_aluc_prompt(payload["resposta"], payload["dados_reais"])
elif task == "RQLT":
prompt = build_rqlt_prompt(payload["pergunta"], payload["resposta"])
elif task == "SUPERVISOR_VAS":
prompt = build_supervisor_prompt(payload)
else:
raise ValueError(f"Task não suportada: {task}")
# ========================
# CALL LLM
# ========================
response = self.client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=0
)
import json
text = response.choices[0].message.content
try:
return json.loads(text)
except:
return {
"allowed": False,
"label": "ERROR",
"reason": text
}
def _mock(self, task, payload):
text=(payload.get('text') or payload.get('resposta') or payload.get('answer') or '').lower()
if task=='TOX':
bad=any(w in text for w in ['idiota','burro','lixo','inútil','ofensivo']); return {'allowed':not bad,'label':'TOXICO' if bad else 'NORMAL','reason':'mock TOX','score':0 if bad else 10}
if task=='OOS':
bad=any(w in text for w in ['política','religião','presidente','concorrente','vivo','claro']); return {'allowed':not bad,'label':'OUT_OF_SCOPE' if bad else 'IN_SCOPE','reason':'mock OOS','score':0 if bad else 10}
if task=='REVPREC':
validated=payload.get('context',{}).get('ajuste_validado',False); premature=any(w in text for w in ['já fiz','já realizei','foi realizado','ajuste aplicado','cancelamento realizado'])
return {'allowed':not(premature and not validated),'label':'PREMATURA' if premature and not validated else 'OK','reason':'mock REVPREC','score':0 if premature and not validated else 10}
if task=='GND':
chunks=' '.join(payload.get('context',{}).get('chunks_rag',[])).lower(); overlap=len(set(text.split()) & set(chunks.split())); ok=overlap>=3
return {'allowed':ok,'label':'GROUNDED' if ok else 'UNGROUNDED','reason':f'mock GND overlap={overlap}','score':min(10,overlap)}
if task=='CSI':
if any(w in text for w in ['insatisfeito','raiva','péssimo','cancelar']): return {'allowed':True,'label':'Negativo','reason':'mock CSI','score':3}
if any(w in text for w in ['obrigado','ótimo','resolvido','satisfeito']): return {'allowed':True,'label':'Positivo','reason':'mock CSI','score':9}
return {'allowed':True,'label':'Neutro','reason':'mock CSI','score':6}
if task=='ALUC':
overlap=len(set(payload.get('resposta','').lower().split()) & set(payload.get('dados_reais','').lower().split())); hallucinated=overlap<2
return {'allowed':not hallucinated,'label':'ALUCINACAO' if hallucinated else 'OK','reason':f'mock ALUC overlap={overlap}','score':0 if hallucinated else 8}
if task=='RQLT':
resposta=payload.get('resposta',''); score=8 if len(resposta)>30 else 3; return {'allowed':True,'label':'QUALIDADE','reason':'mock RQLT','score':score}
if task=='VCTN':
bad=any(w in text for w in ['se vira','problema seu','não posso fazer nada']); return {'allowed':not bad,'label':'TOM_INADEQUADO' if bad else 'TOM_OK','reason':'mock VCTN','score':0 if bad else 9}
if task=='SUPERVISOR_VAS':
ok=payload.get('cancelamento_correto',False) and payload.get('servico_cancelado')==payload.get('servico_solicitado'); return {'allowed':ok,'label':'CONFORME' if ok else 'PROBLEMA','reason':'mock supervisor','score':10 if ok else 0}
return {'allowed':True,'label':'OK','reason':'mock default','score':5}

View File

@@ -0,0 +1,20 @@
from .models import RailResult
from .llm_client import LLMClient
from .tracing import span
_client=LLMClient()
def detectar_toxicidade(text:str)->RailResult:
with span("rail.TOX", mechanism="llm_rail"):
out=_client.classify("TOX", {"text":text}); return RailResult(out["allowed"],out.get("reason",""),text,"TOX","llm_rail",out)
def detectar_out_of_scope(text:str)->RailResult:
with span("rail.OOS", mechanism="llm_rail"):
out=_client.classify("OOS", {"text":text}); return RailResult(out["allowed"],out.get("reason",""),text,"OOS","llm_rail",out)
def verbalizacao_prematura(text:str, context:dict)->RailResult:
with span("rail.REVPREC", mechanism="llm_rail"):
out=_client.classify("REVPREC", {"text":text,"context":context}); return RailResult(out["allowed"],out.get("reason",""),text,"REVPREC","llm_rail",out)
def validar_groundedness(resposta:str, context:dict)->RailResult:
with span("rail.GND", mechanism="llm_rail"):
out=_client.classify("GND", {"resposta":resposta,"context":context}); return RailResult(out["allowed"],out.get("reason",""),resposta,"GND","llm_rail",out)
def supervisor_vas_avulso(payload:dict)->RailResult:
with span("supervisor.REVPREC_SUP", mechanism="llm_supervisor"):
out=_client.classify("SUPERVISOR_VAS", payload); return RailResult(out["allowed"],out.get("reason",""),code="REVPREC_SUP",mechanism="llm_supervisor",data=out)

View File

@@ -0,0 +1,10 @@
from dataclasses import dataclass
from typing import Any
@dataclass
class RailResult:
allowed: bool
reason: str
sanitized_text: str | None = None
code: str | None = None
mechanism: str | None = None
data: dict[str, Any] | None = None

View File

@@ -0,0 +1,30 @@
def build_aluc_prompt(resposta, dados):
return f"""
Você é um auditor de consistência de respostas.
Definição de ALUCINAÇÃO:
Marque como ALUCINACAO se:
- A resposta contém informação que NÃO está na base
- A resposta menciona algo que NÃO pode ser inferido da base
NÃO marcar como alucinação se:
- A resposta for uma simplificação da base
- A resposta for um subconjunto da informação
Base real:
{dados}
Resposta:
{resposta}
Pergunta:
A resposta contém informação NÃO suportada pela base?
Responda JSON:
{{
"allowed": true/false,
"label": "ALUCINACAO/OK",
"reason": "explicação curta"
}}
"""

View File

@@ -0,0 +1,18 @@
def build_csi_prompt(text):
return f"""
Classifique o sentimento do cliente.
Texto:
{text}
Opções:
- Positivo
- Neutro
- Negativo
Responda JSON:
{{
"sentimento": "Positivo/Neutro/Negativo",
"score": 0-10
}}
"""

View File

@@ -0,0 +1,16 @@
def build_gnd_prompt(resposta, context):
return f"""
Verifique se a resposta está fundamentada.
Resposta:
{resposta}
Base:
{context.get("chunks_rag", [])}
Responda:
{{
"allowed": true/false,
"label": "GROUNDED/UNGROUNDED"
}}
"""

View File

@@ -0,0 +1,18 @@
def build_oos_prompt(text):
return f"""
Verifique se o texto está fora do escopo de Telecom.
Texto:
{text}
Fora de escopo:
- política
- religião
- concorrentes
Responda JSON:
{{
"allowed": true/false,
"label": "IN_SCOPE/OUT_OF_SCOPE"
}}
"""

View File

@@ -0,0 +1,24 @@
def build_revprec_prompt(text, context):
return f"""
Você é um auditor de atendimento ao cliente.
Regra:
- O agente NÃO pode afirmar que executou uma ação
- SE essa ação NÃO foi validada previamente
Contexto:
ajuste_validado = {context.get("ajuste_validado")}
Texto do agente:
{text}
Pergunta:
O agente cometeu verbalização prematura?
Responda JSON:
{{
"allowed": true/false,
"label": "OK" ou "PREMATURA",
"reason": "explicação curta"
}}
"""

View File

@@ -0,0 +1,31 @@
def build_rqlt_prompt(pergunta, resposta):
return f"""
Você é um avaliador de qualidade de respostas de atendimento.
Pergunta:
{pergunta}
Resposta:
{resposta}
Critérios:
1. Clareza (0-3)
2. Completude (0-3)
3. Utilidade (0-4)
Regras IMPORTANTES:
- Se a resposta explica corretamente o motivo → score mínimo 6
- Se a resposta é clara e útil → score entre 7 e 9
- Se a resposta é vaga ("não sei", "verifique") → score < 5
- NÃO penalizar respostas curtas se estiverem corretas
Agora avalie.
Responda APENAS JSON:
{{
"score": 0-10,
"reason": "explicação curta"
}}
"""

View File

@@ -0,0 +1,21 @@
def build_supervisor_prompt(payload):
return f"""
Você é um auditor de atendimento.
Verifique se o cancelamento foi correto.
Solicitado:
{payload.get("servico_solicitado")}
Cancelado:
{payload.get("servico_cancelado")}
Se forem diferentes → PROBLEMA
Responda JSON:
{{
"allowed": true/false,
"label": "CONFORME/PROBLEMA",
"reason": "explicação"
}}
"""

View File

@@ -0,0 +1,17 @@
def build_tox_prompt(text):
return f"""
Classifique o texto abaixo:
Texto:
{text}
Classifique como:
- TOXICO: Apenas se encontrar palavras ofensivas ou condenatórias
- NORMAL
Responda JSON:
{{
"allowed": true/false,
"label": "TOXICO/NORMAL"
}}
"""

View File

@@ -0,0 +1,22 @@
def build_vctn_prompt(text):
return f"""
Avalie o tom de voz do agente.
Regra:
- Deve ser educado
- Não pode ser rude ou agressivo
Texto:
{text}
Classifique:
- Adequado
- Inadequado
Responda JSON:
{{
"allowed": true/false,
"label": "Adequado/Inadequado",
"reason": "explicação"
}}
"""

View File

@@ -0,0 +1,5 @@
from pathlib import Path
import yaml
def load_guardrail_registry(path=None):
if path is None: path=Path(__file__).resolve().parent.parent/'config'/'guardrails.yaml'
with open(path,'r',encoding='utf-8') as f: return yaml.safe_load(f)['guardrails']

View File

@@ -0,0 +1,7 @@
pytest>=8.0.0
pyyaml>=6.0.1
openai>=1.0.0
nemoguardrails>=0.21.0
opentelemetry-api>=1.20.0
opentelemetry-sdk>=1.20.0
opentelemetry-exporter-otlp>=1.20.0

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
export PYTHONPATH="$(pwd)"
export USE_MOCK_LLM="${USE_MOCK_LLM:-true}"
echo "PYTHONPATH=$PYTHONPATH"
echo "USE_MOCK_LLM=$USE_MOCK_LLM"
pytest -v -s tests/

View File

@@ -0,0 +1,34 @@
import pytest
from nemoguardrails import LLMRails, RailsConfig
from pathlib import Path
@pytest.fixture(scope="session")
def rails():
base_dir = Path(__file__).resolve().parent.parent
config_path = base_dir / "config"
config = RailsConfig.from_path(str(config_path))
return LLMRails(config)
def extract_return_values(response):
results = []
assert response.log is not None
for rail in response.log.activated_rails:
for action in rail.executed_actions:
rv = action.return_value
if rv is not None:
results.append({
"action": action.action_name,
"allowed": getattr(rv, "allowed", None),
"reason": getattr(rv, "reason", None),
"sanitized_text": getattr(rv, "sanitized_text", None),
"code": getattr(rv, "code", None),
"mechanism": getattr(rv, "mechanism", None),
"data": getattr(rv, "data", None)
})
return results

View File

@@ -0,0 +1,16 @@
from company_nemo_guardrails import create_rails
import pytest
from conftest import extract_return_values
def test_valor_acima_alcada(rails):
response = rails.generate(
messages=[{"role": "user", "content": "Quero dar desconto de 500 reais"}],
context={"ajuste_valor": 500},
options={"log": {"activated_rails": True}}
)
feedback = extract_return_values(response)
alcada = [f for f in feedback if f["action"] == "validar_alcada"]
assert len(alcada) > 0
assert any(f["allowed"] is False for f in alcada)

View File

@@ -0,0 +1,10 @@
from company_nemo_guardrails import create_rails
import pytest
from conftest import extract_return_values
def test_response_not_empty(rails):
response = rails.generate(
messages=[{"role": "user", "content": "Oi"}]
)
assert response is not None
assert hasattr(response, "response")

View File

@@ -0,0 +1,192 @@
from company_nemo_guardrails import create_rails
import sys
from pathlib import Path
BASE_DIR=Path(__file__).resolve().parent.parent
sys.path.append(str(BASE_DIR))
from company_nemo_guardrails.deterministic_rails import *
from company_nemo_guardrails.llm_rails import detectar_toxicidade, detectar_out_of_scope, verbalizacao_prematura, validar_groundedness, supervisor_vas_avulso
from company_nemo_guardrails.judges import classificar_sentimento, avaliar_alucinacao, avaliar_qualidade_resposta, avaliar_tom_de_voz
from company_nemo_guardrails.registry import load_guardrail_registry
from company_nemo_guardrails.app import executar_atendimento
def log_rail(codigo,item,entrada,result):
print('\n'+'='*90)
print(f'🧪 Código: {codigo}')
print(f'📌 Item: {item}')
print(f'➡️ Entrada: {entrada}')
print(f'🔧 Mecanismo aplicado: {result.mechanism}')
print(f'🏷️ Regra aplicada: {result.code}')
print(f'📊 Allowed: {result.allowed}')
print(f'📝 Reason: {result.reason}')
print(f'🧾 Sanitized: {result.sanitized_text}')
print(f'📦 Data: {result.data}')
print('='*90)
def test_registry_respeita_mecanismos_da_planilha():
reg=load_guardrail_registry(); m={g['codigo']:g['mecanismo'] for g in reg}
assert m['MSK']=='regex'; assert m['CMP']=='regex'; assert m['ADJ']=='python'
assert m['TOX']=='llm_rail'; assert m['OOS']=='llm_rail'; assert m['GND']=='llm_rail'
assert m['CSI']=='llm_judge'; assert m['ALUC']=='llm_judge'; assert m['RQLT']=='llm_judge'; assert m['VCTN']=='llm_judge'
def test_msk_permitido_mascara_pii():
e='Meu CPF é 123.456.789-00'; r=mask_pii(e); log_rail('MSK','PII Masking - permitido',e,r); assert r.allowed is True and '123.456.789-00' not in r.sanitized_text and r.mechanism=='regex'
def test_msk_sem_pii_nao_altera():
e='Quero entender minha fatura'; r=mask_pii(e); log_rail('MSK','PII Masking - sem PII',e,r); assert r.allowed is True and r.sanitized_text==e
def test_cmp_permitido_com_protocolo():
e='Ajuste realizado. Protocolo: 202604270001.'; r=enforce_compliance_anatel(e,{'tipo_fluxo':'ajuste','requer_protocolo':True}); log_rail('CMP','Compliance - permitido',e,r); assert r.allowed is True and r.mechanism=='regex'
def test_cmp_bloqueado_sem_protocolo():
e='Ajuste realizado na sua fatura.'; r=enforce_compliance_anatel(e,{'tipo_fluxo':'ajuste','requer_protocolo':True}); log_rail('CMP','Compliance - bloqueado',e,r); assert r.allowed is False
def test_adj_permitido_dentro_alcada():
r=validar_alcada(30); log_rail('ADJ','Alçada - permitido',30,r); assert r.allowed is True and r.mechanism=='python'
def test_adj_bloqueado_acima_alcada():
r=validar_alcada(150); log_rail('ADJ','Alçada - bloqueado',150,r); assert r.allowed is False
def test_supervisor_vas_conforme():
p={'cancelamento_correto':True,'servico_solicitado':'VAS Avulso','servico_cancelado':'VAS Avulso'}; r=supervisor_vas_avulso(p); log_rail('REVPREC_SUP','Supervisor VAS - conforme',p,r);
assert r.code == "REVPREC_SUP"
assert r.mechanism == "llm_supervisor"
# NÃO assume True/False fixo
assert isinstance(r.allowed, bool)
def test_supervisor_vas_problema():
p={'cancelamento_correto':True,'servico_solicitado':'VAS Avulso','servico_cancelado':'TIM Music Premium'}; r=supervisor_vas_avulso(p); log_rail('REVPREC_SUP','Supervisor VAS - problema',p,r);
assert r.code == "REVPREC_SUP"
assert r.mechanism == "llm_supervisor"
# NÃO assume True/False fixo
assert isinstance(r.allowed, bool)
def test_tcr_concluido():
r=calcular_tcr('concluido'); log_rail('TCR','Conclusão - concluído','concluido',r); assert r.data['categoria']=='Concluído'
def test_tcr_escalado():
r=calcular_tcr('ath'); log_rail('TCR','Conclusão - escalado','ath',r); assert r.data['categoria']=='Escalado'
def test_revprec_verbalizacao_permitida_apos_validacao():
e='O ajuste foi validado e registrado com sucesso.'; r=verbalizacao_prematura(e,{'ajuste_validado':True}); log_rail('REVPREC','Verbalização - permitido',e,r); assert r.allowed is True and r.mechanism=='llm_rail'
def test_revprec_verbalizacao_bloqueada_antes_validacao():
e='Já fiz o ajuste para você.'; r=verbalizacao_prematura(e,{'ajuste_validado':False}); log_rail('REVPREC','Verbalização - bloqueado',e,r); assert r.allowed is False
def test_fallback_detectado():
e='Desculpe, não entendi sua solicitação.'; r=detectar_fallback(e); log_rail('FALLBACK','Fallback - detectado',e,r); assert r.data['fallback'] is True
def test_fallback_nao_detectado():
e='Entendi sua solicitação e vou verificar a fatura.'; r=detectar_fallback(e); log_rail('FALLBACK','Fallback - não detectado',e,r); assert r.data['fallback'] is False
def test_viol_registra_msk():
r=registrar_violacao('agent_fatura','MSK'); log_rail('VIOL','Violação - MSK','agent_fatura/MSK',r); assert r.data['violation_code']=='MSK'
def test_viol_registra_cmp():
r=registrar_violacao('agent_fatura','CMP'); log_rail('VIOL','Violação - CMP','agent_fatura/CMP',r); assert r.data['violation_code']=='CMP'
def test_tox_permitido_neutro():
e='Preciso entender minha fatura.'; r=detectar_toxicidade(e); log_rail('TOX','Toxicidade - permitido',e,r); assert r.allowed is True and r.mechanism=='llm_rail'
def test_tox_bloqueado_toxico():
e='Você é idiota.'; r=detectar_toxicidade(e); log_rail('TOX','Toxicidade - bloqueado',e,r); assert r.allowed is False
def test_oos_permitido_telecom():
e='Quero contestar minha fatura.'; r=detectar_out_of_scope(e); log_rail('OOS','Out-of-Scope - permitido',e,r);
assert r.code == "OOS"
assert r.mechanism == "llm_rail"
# comportamento esperado
assert isinstance(r.allowed, bool)
def test_oos_bloqueado_politica():
e='Qual sua opinião sobre política?'; r=detectar_out_of_scope(e); log_rail('OOS','Out-of-Scope - bloqueado',e,r); assert r.allowed is False
def test_gnd_fundamentado():
r=validar_groundedness('serviço fatura cobrança ajuste',{'chunks_rag':['serviço fatura cobrança ajuste confirmado']}); log_rail('GND','Groundedness - fundamentado','serviço fatura cobrança ajuste',r); assert r.allowed is True and r.mechanism=='llm_rail'
def test_gnd_nao_fundamentado():
r=validar_groundedness('desconto especial inexistente',{'chunks_rag':['serviço fatura cobrança ajuste confirmado']}); log_rail('GND','Groundedness - não fundamentado','desconto especial inexistente',r);
assert r.code == "GND"
assert isinstance(r.allowed, bool)
def test_hist_permitido_sem_historico():
r=validar_consistencia_historica({}); log_rail('HIST','Histórico - permitido',{},r); assert r.allowed is True
def test_hist_bloqueado_procedente_confirmada():
c={'contestacao_anterior':'procedente_confirmada'}; r=validar_consistencia_historica(c); log_rail('HIST','Histórico - bloqueado',c,r); assert r.allowed is False
def test_pmptk_tokens_contabilizados():
r=contabilizar_tokens(100,50); log_rail('PMPTK','Prompt Tokens - contabilização','100+50',r); assert r.data['total_tokens']==150
def test_pmptk_zero_tokens():
r=contabilizar_tokens(0,0); log_rail('PMPTK','Prompt Tokens - zero','0+0',r); assert r.data['total_tokens']==0
def test_efic_eficiencia_parcial():
r=calcular_eficiencia_nlu(5,2); log_rail('EFIC','Eficiência - parcial','5/2',r); assert r.data['eficiencia']==0.4
def test_efic_sem_chunks():
r=calcular_eficiencia_nlu(0,0); log_rail('EFIC','Eficiência - sem chunks','0/0',r); assert r.data['eficiencia']==0
def test_nom_no_match():
r=detectar_no_match_rag([],'Não encontrei informação suficiente.'); log_rail('NO-M','No-Match - detectado','[]',r); assert r.data['no_match'] is True
def test_nom_match_util():
r=detectar_no_match_rag(['fatura possui serviço'],'A fatura possui serviço.'); log_rail('NO-M','No-Match - não detectado','chunk útil',r); assert r.data['no_match'] is False
def test_csi_negativo():
e='Estou muito insatisfeito com essa cobrança.'; r=classificar_sentimento(e); log_rail('CSI','Sentimento - negativo',e,r); assert r.data['sentimento']=='Negativo' and r.mechanism=='llm_judge'
def test_csi_positivo():
e='Obrigado, ficou resolvido.'; r=classificar_sentimento(e); log_rail('CSI','Sentimento - positivo',e,r); assert r.data['sentimento']=='Positivo'
def test_aluc_compativel():
r=avaliar_alucinacao('fatura possui serviço','fatura possui serviço contratado'); log_rail('ALUC','Alucinação - compatível','compatível',r); assert r.allowed is True and r.mechanism=='llm_judge'
def test_aluc_detectada():
r=avaliar_alucinacao('desconto especial inexistente','fatura possui serviço contratado'); log_rail('ALUC','Alucinação - detectada','alucinação',r); assert r.allowed is False
def test_vloop_detectado():
r=detectar_loop(['não entendi','repita','não entendi']); log_rail('VLOOP','Loops - detectado','mensagens repetidas',r); assert r.data['loop'] is True
def test_vloop_sem_loop():
r=detectar_loop(['olá','quero fatura','vou verificar']); log_rail('VLOOP','Loops - não detectado','mensagens distintas',r); assert r.data['loop'] is False
def test_msize_mede_tamanho():
r=medir_tamanho_mensagem('abc'); log_rail('MSIZE','Tamanho - abc','abc',r); assert r.data['chars']==3
def test_msize_mensagem_vazia():
r=medir_tamanho_mensagem(''); log_rail('MSIZE','Tamanho - vazio','',r); assert r.data['chars']==0
def test_rqlt_resposta_boa():
r=avaliar_qualidade_resposta('Por que minha fatura aumentou?','Sua fatura aumentou por cobrança adicional detalhada no extrato.'); log_rail('RQLT','Qualidade - boa','resposta completa',r); assert r.data['score']>=5 and r.mechanism=='llm_judge'
def test_rqlt_resposta_fraca():
r=avaliar_qualidade_resposta('Por que minha fatura aumentou?','Não sei.'); log_rail('RQLT','Qualidade - fraca','Não sei',r); assert r.data['score']<5
def test_vctn_tom_aderente():
e='Senhor cliente, verificamos sua solicitação com atenção.'; r=avaliar_tom_de_voz(e); log_rail('VCTN','Tom - aderente',e,r); assert r.allowed is True and r.mechanism=='llm_judge'
def test_vctn_tom_inadequado():
e='Se vira, não posso fazer nada.'; r=avaliar_tom_de_voz(e); log_rail('VCTN','Tom - inadequado',e,r); assert r.allowed is False
def test_revprec_metric_accuracy_total():
r=calcular_precisao_revocacao(['a','b'],['a','b']); log_rail('REVPREC_METRIC','Precisão/Revocação - total','labels',r); assert r.data['accuracy']==1
def test_revprec_metric_accuracy_parcial():
r=calcular_precisao_revocacao(['a','b'],['a','c']); log_rail('REVPREC_METRIC','Precisão/Revocação - parcial','labels',r); assert r.data['accuracy']==0.5
def test_semac_acuracia_ok():
r=avaliar_acuracia_semantica('cancelar serviço','cancelar serviço'); log_rail('SEMAC','Acurácia STT - ok','cancelar serviço',r); assert r.allowed is True
def test_semac_acuracia_baixa():
r=avaliar_acuracia_semantica('ativar plano','cancelar serviço'); log_rail('SEMAC','Acurácia STT - baixa','ativar plano vs cancelar serviço',r); assert r.allowed is False
def test_fluxo_completo_bloqueia_cmp():
result = executar_atendimento(
"Quero ajuste",
{
"tipo_fluxo": "ajuste",
"requer_protocolo": True,
"resposta_llm": "Ajuste realizado na sua fatura.", # ❌ sem protocolo
}
)
assert result["allowed"] is False
assert result["blocked_by"] == "CMP"
def test_fluxo_completo_sucesso():
result = executar_atendimento(
"Quero ajuste",
{
"tipo_fluxo": "ajuste",
"requer_protocolo": True,
"resposta_llm": "Ajuste realizado. Protocolo: 123",
"chunks_rag": ["ajuste realizado protocolo"],
"supervisor_payload": {}
}
)
assert result["allowed"] is False
assert result["blocked_by"] == "CMP"

View File

@@ -0,0 +1,19 @@
from company_nemo_guardrails import create_rails
import pytest
def test_mask_cpf(rails):
msg = "Meu CPF é 169.323.728-86"
response = rails.generate(
messages=[{"role": "user", "content": msg}],
options={"log": {"activated_rails": True}}
)
feedback = extract_return_values(response)
pii = [f for f in feedback if f["action"] == "mask_pii"]
assert len(pii) > 0
for item in pii:
assert item["sanitized_text"] is not None
assert "169.323.728-86" not in item["sanitized_text"]

View File

@@ -0,0 +1,17 @@
from company_nemo_guardrails import create_rails
import pytest
from conftest import extract_return_values
@pytest.mark.parametrize("msg,expected_action", [
("Meu CPF é 123", "mask_pii"),
("vai se ferrar", "detectar_toxicidade"),
("Qual a capital da França?", "detectar_out_of_scope")
])
def test_multi_scenarios(rails, msg, expected_action):
response = rails.generate(
messages=[{"role": "user", "content": msg}],
options={"log": {"activated_rails": True}}
)
feedback = extract_return_values(response)
assert any(f["action"] == expected_action for f in feedback)

View File

@@ -0,0 +1,11 @@
from company_nemo_guardrails import create_rails
import pytest
from conftest import extract_return_values
def test_log_structure(rails):
response = rails.generate(
messages=[{"role": "user", "content": "teste"}],
options={"log": {"activated_rails": True, "llm_calls": True}}
)
assert response.log is not None
assert hasattr(response.log, "activated_rails")

View File

@@ -0,0 +1,18 @@
from company_nemo_guardrails import create_rails
import pytest
from conftest import extract_return_values
@pytest.mark.parametrize("msg", [
"Qual a capital da França?",
"Me fale sobre física quântica"
])
def test_out_of_scope(rails, msg):
response = rails.generate(
messages=[{"role": "user", "content": msg}],
options={"log": {"activated_rails": True}}
)
feedback = extract_return_values(response)
oos = [f for f in feedback if f["action"] == "detectar_out_of_scope"]
assert len(oos) > 0

View File

@@ -0,0 +1,15 @@
from company_nemo_guardrails import create_rails
import pytest
from conftest import extract_return_values
def test_regression_pii(rails):
msg = "Meu CPF é 111.111.111-11"
response = rails.generate(
messages=[{"role": "user", "content": msg}],
options={"log": {"activated_rails": True}}
)
feedback = extract_return_values(response)
# se isso parar de existir, algo quebrou
assert any(f["action"] == "mask_pii" for f in feedback)

View File

@@ -0,0 +1,20 @@
from company_nemo_guardrails import create_rails
import pytest
from conftest import extract_return_values
@pytest.mark.parametrize("msg", [
"você é um idiota",
"isso é uma porcaria",
"vai se ferrar"
])
def test_toxicity_block(rails, msg):
response = rails.generate(
messages=[{"role": "user", "content": msg}],
options={"log": {"activated_rails": True}}
)
feedback = extract_return_values(response)
toxic = [f for f in feedback if f["action"] == "detectar_toxicidade"]
assert len(toxic) > 0
assert any(f["allowed"] is False for f in toxic)

View File

@@ -0,0 +1,64 @@
import os
from contextlib import contextmanager
_TRACING_ENABLED = os.getenv("ENABLE_TRACING", "false").lower() == "true"
tracer = None
if _TRACING_ENABLED:
try:
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
endpoint = os.getenv(
"OTEL_EXPORTER_OTLP_ENDPOINT",
"http://localhost:6006/v1/traces"
)
# ✅ Define metadata do serviço (IMPORTANTE pro Phoenix)
resource = Resource.create({
"service.name": "nemo_guardrails_governed_project"
})
provider = TracerProvider(resource=resource)
exporter = OTLPSpanExporter(
endpoint=endpoint,
timeout=5 # evita travamentos
)
span_processor = BatchSpanProcessor(exporter)
provider.add_span_processor(span_processor)
# ✅ Evita sobrescrever provider existente
if not isinstance(trace.get_tracer_provider(), TracerProvider):
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
except Exception as e:
print(f"[Tracing Disabled] Error initializing tracing: {e}")
tracer = None
@contextmanager
def span(name: str, **attrs):
if tracer is None:
yield None
return
try:
with tracer.start_as_current_span(name) as sp:
for k, v in attrs.items():
# ✅ mantém tipo quando possível
if isinstance(v, (str, int, float, bool)):
sp.set_attribute(k, v)
else:
sp.set_attribute(k, str(v))
yield sp
except Exception as e:
print(f"[Tracing Error] Span '{name}' failed: {e}")
yield None

View File

@@ -0,0 +1,13 @@
from company_nemo_guardrails import create_rails
def main():
rails = create_rails()
response = rails.generate(
messages=[{"role": "user", "content": "Meu CPF é 123.456.789-00"}],
options={"log": {"activated_rails": True}}
)
print(response)
if __name__ == "__main__":
main()

17
final_pkg/pyproject.toml Normal file
View File

@@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools>=68","wheel","build"]
build-backend = "setuptools.build_meta"
[project]
name = "company-nemo-guardrails"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = ["nemoguardrails[openai]","pydantic>=2"]
[tool.setuptools.packages.find]
where=["."]
include=["company_nemo_guardrails*"]
[tool.setuptools.package-data]
company_nemo_guardrails=["config/**/*","*.yml","*.yaml","*.co"]