from __future__ import annotations import json, re from typing import Any from agent_framework.guardrails.base import Guardrail, RailDecision from .tim_prompts.ausencia_oferta_proativa import build_aoferta_prompt from .tim_prompts.out_of_scope import build_oos_prompt from .tim_prompts.revprec import build_revprec_prompt from .tim_prompts.fraseologia import build_fraseologia_prompt def _llm(context: dict[str, Any]): return context.get('guardrail_llm') or context.get('llm') or context.get('model') def _context_text(context: dict[str, Any]) -> str: try: return json.dumps(context or {}, ensure_ascii=False, default=str)[:16000] except Exception: return str(context or {})[:16000] def _authorized_human_handoff(context: dict[str, Any]) -> bool: """Return True only for structurally authorized human handoff on this turn.""" ctx = context or {} route = str(ctx.get('current_route') or ctx.get('route') or '').strip().lower() intent = str(ctx.get('current_intent') or ctx.get('intent') or '').strip().lower() session_control = str(ctx.get('session_control') or '').strip().upper() requested = ctx.get('human_handoff_requested') is True handoff = ctx.get('handoff') is True route_decision = ctx.get('route_decision') if isinstance(ctx.get('route_decision'), dict) else {} route_meta = route_decision.get('metadata') if isinstance(route_decision.get('metadata'), dict) else {} rd_route = str(route_decision.get('route') or route_decision.get('agent') or '').strip().lower() rd_intent = str(route_decision.get('intent') or '').strip().lower() rd_handoff = route_decision.get('handoff') is True rd_session_control = str(route_meta.get('session_control') or '').strip().upper() control_evidence = ( session_control == 'HUMAN_HANDOFF' or requested or handoff or rd_handoff or rd_session_control == 'HUMAN_HANDOFF' ) route_evidence = ( route == 'human_handoff' or intent == 'human_handoff' or rd_route == 'human_handoff' or rd_intent == 'human_handoff' ) if control_evidence and route_evidence: return True # A resumed domain workflow may decide the handoff after the router has # already been bypassed for workflow continuation. In that case the # current route/intent legitimately remain the domain values, while the # *current-turn workflow result* is the authoritative orchestration # decision. Accept only a terminal, internally consistent handoff result; # an isolated `handoff=true` or transfer-like sentence is never enough. roots = [] for key in ('mcp_results', 'tool_result', 'workflow_result'): value = ctx.get(key) if value is not None: roots.append(value) def terminal_workflow_handoff(value: Any) -> bool: if isinstance(value, dict): workflow_control = str(value.get('session_control') or '').strip().upper() workflow_terminal = str(value.get('terminal_status') or '').strip().lower() workflow_requested = value.get('human_handoff_requested') is True workflow_handoff = value.get('handoff') is True workflow_session_ended = value.get('session_ended') is True has_control = workflow_control == 'HUMAN_HANDOFF' has_request = workflow_requested or workflow_handoff has_terminal = workflow_terminal == 'human_handoff' or workflow_session_ended if has_control and has_request and has_terminal: return True return any(terminal_workflow_handoff(item) for item in value.values()) if isinstance(value, (list, tuple)): return any(terminal_workflow_handoff(item) for item in value) return False return any(terminal_workflow_handoff(root) for root in roots) def _parse_json(raw: Any) -> dict[str, Any]: text = str(getattr(raw, 'content', raw) or '').strip() m = re.search(r'\{[\s\S]*\}', text) if m: text=m.group(0) try: return json.loads(text) except Exception: return {'allowed': False, 'reason': f'Resposta inválida do guardrail TIM: {text[:300]}'} class _TimPromptRail(Guardrail): prompt_builder = None profile_name = 'guardrail' async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision: llm = _llm(context) if llm is None: return RailDecision(code=self.code, allowed=False, reason='LLM do framework indisponível para guardrail TIM', metadata={'external': True, 'fail_closed': True}) prompt = self.prompt_builder(text or '', _context_text(context)) raw = await llm.ainvoke( [{'role':'system','content':'Responda apenas JSON válido, sem markdown.'}, {'role':'user','content':prompt}], profile_name=self.profile_name, component_name=f'guardrail.external.{self.code.lower()}', generation_name=f'guardrail.external.{self.code.lower()}', ) out = _parse_json(raw) return RailDecision(code=self.code, allowed=bool(out.get('allowed', False)), reason=str(out.get('reason') or out.get('label') or ''), sanitized_text=text, metadata={'external': True, 'domain':'TIM_CONTAS', 'data':out}) class TimOutOfScopeRail(_TimPromptRail): code='TIM_OOS'; stage='output'; prompt_builder=staticmethod(build_oos_prompt) async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision: # A handoff structurally selected by the router/workflow is an authorized # orchestration response, not a domain answer to be judged as OOS by text. if _authorized_human_handoff(context): return RailDecision( code=self.code, allowed=True, reason='handoff_humano_autorizado', sanitized_text=text, metadata={ 'external': True, 'domain': 'TIM_CONTAS', 'mechanism': 'deterministic_handoff_bypass', 'data': { 'allowed': True, 'reason': 'handoff_humano_autorizado', }, }, ) return await super().evaluate(text, context) class TimProactiveOfferRail(_TimPromptRail): code='TIM_AOFERTA'; stage='output'; prompt_builder=staticmethod(build_aoferta_prompt) # Mantém a mesma semântica do AOFERTA nativo do framework: mensagens de # continuidade de uma transação já aberta não constituem nova oferta # proativa. O bypass é estritamente dirigido pelo estado transacional, sem # inferência textual e sem desabilitar outros guardrails de saída. _TRANSACTION_CONTINUATION_STATUSES = { 'COLLECTING_PARAMETERS', 'AWAITING_CONFIRMATION', } @staticmethod def _authorized_human_handoff(context: dict[str, Any]) -> bool: return _authorized_human_handoff(context) @classmethod def _transaction_continuation_status(cls, context: dict[str, Any]) -> str | None: ctx = context or {} status = str(ctx.get('transaction_status') or '').strip().upper() if status in cls._TRANSACTION_CONTINUATION_STATUSES: return status # Compatibilidade com o contexto de output do Contas e com callers que # expõem o estado apenas no resultado da tool. É o mesmo fallback usado # pelo ProactiveOfferRail nativo: mcp_results -> tool_result. results = ctx.get('mcp_results') or ctx.get('tool_result') or [] if isinstance(results, dict): results = [results] for result in reversed(list(results)): if not isinstance(result, dict): continue result_status = str(result.get('transaction_status') or '').strip().upper() if result_status in cls._TRANSACTION_CONTINUATION_STATUSES: return result_status return None async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision: if self._authorized_human_handoff(context): return RailDecision( code=self.code, allowed=True, reason='handoff_humano_autorizado', sanitized_text=text, metadata={ 'external': True, 'domain': 'TIM_CONTAS', 'mechanism': 'deterministic_handoff_bypass', 'data': { 'allowed': True, 'reason': 'handoff_humano_autorizado', }, }, ) continuation_status = self._transaction_continuation_status(context) if continuation_status: return RailDecision( code=self.code, allowed=True, reason=f'continuidade_transacional:{continuation_status}', sanitized_text=text, metadata={ 'external': True, 'domain': 'TIM_CONTAS', 'mechanism': 'deterministic_transaction_bypass', 'transaction_status': continuation_status, 'data': { 'allowed': True, 'reason': f'continuidade_transacional:{continuation_status}', }, }, ) return await super().evaluate(text, context) class TimPrematureActionRail(_TimPromptRail): code='TIM_REVPREC'; stage='output'; profile_name='grl'; prompt_builder=staticmethod(build_revprec_prompt) @staticmethod def _structured_insufficient_evidence_message(text: str, context: dict[str, Any]) -> bool: """Allow an epistemically conservative tool/workflow answer without LLM re-judgment. The bypass is intentionally structural and exact-message based. An arbitrary assistant sentence saying "não sei" is *not* enough: the current-turn tool result must explicitly declare ``epistemic_status=insufficient_evidence`` and expose the same ``mensagem`` that is being sent to the customer. """ expected = " ".join(str(text or "").split()) if not expected: return False roots = [] ctx = context or {} for key in ('mcp_results', 'tool_result', 'evidence'): value = ctx.get(key) if value is not None: roots.append(value) def walk(value: Any) -> bool: if isinstance(value, dict): status = str(value.get('epistemic_status') or '').strip().lower() message = " ".join(str(value.get('mensagem') or '').split()) if status == 'insufficient_evidence' and message and message == expected: return True return any(walk(item) for item in value.values()) if isinstance(value, (list, tuple)): return any(walk(item) for item in value) return False return any(walk(root) for root in roots) async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision: if self._structured_insufficient_evidence_message(text, context): return RailDecision( code=self.code, allowed=True, reason='insufficient_evidence_non_assertive', sanitized_text=text, metadata={ 'external': True, 'domain': 'TIM_CONTAS', 'mechanism': 'deterministic_epistemic_bypass', 'epistemic_status': 'insufficient_evidence', }, ) llm = _llm(context) if llm is None: return RailDecision(code=self.code, allowed=False, reason='LLM do framework indisponível para guardrail TIM', metadata={'external': True, 'fail_closed': True}) prompt = self.prompt_builder(text or '', _context_text(context)) raw = await llm.ainvoke( [{'role':'system','content':'Responda apenas com o dígito 1 ou 0, sem texto adicional.'}, {'role':'user','content':prompt}], profile_name=self.profile_name, component_name='guardrail.external.tim_revprec', generation_name='guardrail.external.tim_revprec') output = str(getattr(raw, 'content', raw) or '').strip() digits = [ch for ch in output if ch in '01'] # Prompt original: 1 = violação / verbalização prematura; 0 = permitido. allowed = not digits or digits[-1] != '1' return RailDecision(code=self.code, allowed=allowed, reason='' if allowed else 'verbalização prematura segundo política TIM Contas', sanitized_text=text, metadata={'external':True,'domain':'TIM_CONTAS','raw':output[:100], 'terminal_action':'retry'}) class TimPhraseologyRail(_TimPromptRail): code='TIM_FRASEOLOGIA'; stage='output'; profile_name='grl'; prompt_builder=staticmethod(build_fraseologia_prompt) async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision: result = await super().evaluate(text, context) result.metadata = { **dict(result.metadata or {}), 'remediation': { 'type': 'rewrite', 'max_attempts': 1, 'prompt_id': 'FALLBACK', 'profile_name': 'grl', 'component_name': 'guardrail.wording.rewrite', 'generation_name': 'guardrail.wording.rewrite', }, } return result