This commit is contained in:
2026-06-13 15:40:44 -03:00
parent 0714b503d1
commit 1f1188dd4e
181 changed files with 1345 additions and 849 deletions

Binary file not shown.

View File

@@ -94,7 +94,7 @@ class BackofficeAgent(AgentRuntimeMixin):
"BACKOFFICE_TICKET_CONTEXT_DETECTED",
normalized_state,
{
"status": "Contexto de ticket detectado; execução checklist deve ocorrer pelo BackofficeNativeRuntime nas rotas /agent/*",
"status": "Contexto de ticket detectado; execução operacional deve entrar como canal backoffice_rest via ChannelGateway/dispatcher",
"framework_native": True,
"reason": "o agente conversacional não compila nem executa grafos de domínio",
},
@@ -383,14 +383,14 @@ class BackofficeAgent(AgentRuntimeMixin):
A migração framework-native não permite que o agente conversacional
compile/execute ``src.agent.graphs`` diretamente. Os fluxos checklist e
response emulator são executados pelo BackofficeNativeRuntime chamado
pelas rotas/adapters do backend.
response emulator são executados pelo dispatcher de workflows após
normalização pelo canal backoffice_rest/ChannelGateway.
"""
return {
"executed": False,
"error": {
"type": "DeprecatedDirectGraphExecution",
"message": "Use BackofficeNativeRuntime.execute_workflow('backoffice_checklist')",
"message": "Use BackofficeRestChannelAdapter + BackofficeWorkflowDispatcher",
},
}

10
app/channels/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
"""Channel adapters owned by the application domain.
The framework ChannelGateway remains the canonical normalization boundary.
Domain REST contracts should enter the backend through adapters in this package
instead of calling workflow runtimes directly from FastAPI routes.
"""
from .backoffice_rest_adapter import BackofficeChannelEnvelope, BackofficeRestChannelAdapter
__all__ = ["BackofficeChannelEnvelope", "BackofficeRestChannelAdapter"]

Binary file not shown.

View File

@@ -0,0 +1,192 @@
from __future__ import annotations
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import Any
from uuid import uuid4
@dataclass(slots=True)
class BackofficeChannelEnvelope:
"""Canonical channel envelope for Backoffice REST requests.
REST remains only a transport/channel contract. This envelope carries the
normalized information required by the framework layers before a workflow is
dispatched. The domain payload is preserved under ``payload`` so existing
business nodes can continue to read the original TIM/ANATEL contract from
``state["metadata"]["request_context"]``.
"""
workflow_id: str
payload: dict[str, Any]
transaction_id: str
channel: str = "backoffice_rest"
tenant_id: str = "default"
agent_id: str = "backoffice_anatel"
user_id: str | None = None
message_id: str = field(default_factory=lambda: str(uuid4()))
text: str = ""
business_context: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
def gateway_payload(self) -> dict[str, Any]:
"""Payload sent to the framework ChannelGateway.
The web adapter shape is intentionally also populated as a compatibility
fallback for framework versions that do not yet register a dedicated
``backoffice_rest`` adapter.
"""
return {
"channel": self.channel,
"message": self.text,
"text": self.text,
"session_id": self.transaction_id,
"conversation_key": f"{self.tenant_id}:{self.agent_id}:{self.transaction_id}",
"message_id": self.message_id,
"user_id": self.user_id or self.business_context.get("customer_key") or self.transaction_id,
"tenant_id": self.tenant_id,
"agent_id": self.agent_id,
"business_context": self.business_context,
"metadata": {
**self.metadata,
"workflow_id": self.workflow_id,
"transaction_id": self.transaction_id,
"source_channel": self.channel,
"request_context": self.payload,
},
}
class BackofficeRestChannelAdapter:
"""Adapter that turns legacy Backoffice REST contracts into a framework channel.
This class deliberately contains translation only. It does not run LangGraph,
does not know node order and does not call domain services. Execution is
delegated to the framework workflow dispatcher after channel normalization.
"""
channel_name = "backoffice_rest"
agent_id = "backoffice_anatel"
tenant_id = "default"
def from_ticket_event(self, event: Any, *, legacy_contract: str) -> BackofficeChannelEnvelope:
payload = self._to_dict(event)
transaction_id = payload.get("transactionId") or payload.get("transaction_id") or f"man-{uuid4().hex[:8]}"
complaint = payload.get("complaint") or {}
customer = payload.get("customer") or {}
text = self._first_non_empty(
complaint.get("description"),
complaint.get("motive"),
payload.get("description"),
f"Processar chamado backoffice {transaction_id}",
)
business_context = self._business_context(payload, transaction_id)
return BackofficeChannelEnvelope(
workflow_id="backoffice_checklist",
payload=payload,
transaction_id=transaction_id,
channel=self.channel_name,
tenant_id=self.tenant_id,
agent_id=self.agent_id,
user_id=customer.get("cpfCnpj") or customer.get("msisdn") or business_context.get("customer_key"),
text=text,
business_context=business_context,
metadata={
"legacy_contract": legacy_contract,
"case_type": payload.get("caseType"),
"complaint_protocol": complaint.get("complaintProtocol"),
"crm_protocol": payload.get("crmProtocol"),
"adapter": self.__class__.__name__,
},
)
def from_emulator_event(self, event: Any, *, legacy_contract: str) -> BackofficeChannelEnvelope:
payload = self._to_dict(event)
transaction_id = payload.get("transactionId") or payload.get("transaction_id") or f"emu-{uuid4().hex[:8]}"
selected_actions = payload.get("selected_actions") or payload.get("selectedActions") or []
flow_mode = payload.get("flow_mode") or payload.get("flowMode") or payload.get("action")
text = self._first_non_empty(
payload.get("operator_instructions"),
payload.get("previous_response"),
f"Executar emulador de resposta do caso {transaction_id}",
)
return BackofficeChannelEnvelope(
workflow_id="backoffice_response_emulator",
payload=payload,
transaction_id=transaction_id,
channel=self.channel_name,
tenant_id=self.tenant_id,
agent_id=self.agent_id,
user_id=transaction_id,
text=text,
business_context={"interaction_key": transaction_id, "session_key": transaction_id},
metadata={
"legacy_contract": legacy_contract,
"flow_mode": flow_mode,
"selected_actions": selected_actions,
"adapter": self.__class__.__name__,
},
)
async def normalize(self, channel_gateway: Any, envelope: BackofficeChannelEnvelope) -> Any:
"""Normalize through ChannelGateway, with a compatibility fallback.
Newer framework versions may support registering ``backoffice_rest`` as a
first-class adapter. Older versions only know web/whatsapp/voice; in that
case the payload is still passed through the web adapter shape and then
tagged back as ``backoffice_rest``.
"""
payload = envelope.gateway_payload()
try:
msg = await channel_gateway.normalize(envelope.channel, payload)
return msg
except Exception:
try:
msg = await channel_gateway.normalize("web", payload)
try:
msg.channel = envelope.channel
except Exception:
pass
return msg
except Exception:
return SimpleNamespace(
channel=envelope.channel,
channel_id=None,
session_id=envelope.transaction_id,
user_id=envelope.user_id,
text=envelope.text,
context=payload,
)
def _business_context(self, payload: dict[str, Any], transaction_id: str) -> dict[str, Any]:
customer = payload.get("customer") or {}
complaint = payload.get("complaint") or {}
cpf = customer.get("cpfCnpj") or (customer.get("subscriber") or {}).get("cpfCnpj")
msisdn = customer.get("msisdn") or ((customer.get("phones") or [])[:1] or [None])[0]
protocol = complaint.get("complaintProtocol") or payload.get("crmProtocol") or transaction_id
return {
"customer_key": msisdn or cpf,
"contract_key": cpf,
"interaction_key": protocol,
"session_key": transaction_id,
"metadata": {
"cpf_cnpj": cpf,
"msisdn": msisdn,
"transaction_id": transaction_id,
},
}
def _to_dict(self, value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return dict(value)
if hasattr(value, "model_dump"):
return value.model_dump(mode="json", by_alias=True)
if hasattr(value, "dict"):
return value.dict(by_alias=True)
raise TypeError(f"Unsupported backoffice REST event type: {type(value).__name__}")
def _first_non_empty(self, *values: Any) -> str:
for value in values:
if value is not None and str(value).strip():
return str(value).strip()
return "Backoffice REST request"

View File

@@ -32,7 +32,9 @@ from agent_framework.cache.cache import create_cache
from agent_framework.billing.usage_repository import create_usage_repository
from agent_framework.sse.events import SSEHub
from app.workflows.agent_graph import AgentWorkflow
from app.workflows.backoffice_native_runtime import BackofficeNativeRuntime
from app.workflows.backoffice_workflow_executor import BackofficeWorkflowExecutor
from app.channels.backoffice_rest_adapter import BackofficeRestChannelAdapter
from app.workflows.backoffice_workflow_dispatcher import BackofficeWorkflowDispatcher
from app.identity_extraction import enrich_payload_with_text_identity, extract_identity_from_text
logging.basicConfig(level=settings.LOG_LEVEL)
@@ -69,7 +71,9 @@ identity_resolver = IdentityResolver.from_yaml(settings.IDENTITY_CONFIG_PATH)
agent_profiles = AgentProfileRegistry(settings)
sse_hub = SSEHub(settings, telemetry=telemetry)
workflow = AgentWorkflow(llm, memory, telemetry, analytics, settings, observer=observer, tool_router=tool_router, summary_memory=summary_memory)
backoffice_runtime = BackofficeNativeRuntime(settings=settings, telemetry=telemetry, analytics=analytics, observer=observer)
backoffice_executor = BackofficeWorkflowExecutor(settings=settings, telemetry=telemetry, analytics=analytics, observer=observer)
backoffice_rest_adapter = BackofficeRestChannelAdapter()
backoffice_dispatcher = BackofficeWorkflowDispatcher(channel_gateway=gateway, executor=backoffice_executor, telemetry=telemetry, adapter=backoffice_rest_adapter)
logger.info("LLM provider carregado: %s", llm.__class__.__name__)
logger.info("Langfuse habilitado: %s host=%s", telemetry.is_enabled(), settings.LANGFUSE_HOST)
@@ -498,12 +502,12 @@ async def shutdown():
# ---------------------------------------------------------------------------
# Backoffice TIM/ANATEL develop — execução 100% framework-native
# Backoffice TIM/ANATEL develop — REST como canal do framework
# ---------------------------------------------------------------------------
# As rotas antigas permanecem como adapters REST, mas não registram routers
# legados e não executam legacy graph package nem legacy executor package.
# Elas chamam o BackofficeNativeRuntime, que compila os workflows com o motor
# do framework e aplica guardrails, judges, supervisor, checkpoint e telemetry.
# As rotas antigas permanecem como contratos HTTP de compatibilidade, mas agora
# atuam apenas como REST adapters. Elas traduzem o payload legado para um
# BackofficeChannelEnvelope, passam pelo ChannelGateway e só então o dispatcher
# aciona o workflow LangGraph registrado para o domínio.
from src.api.schemas.anatel_schemas import TicketRequestEvent
from src.api.schemas.anatel_response_emulator_schemas import EmulatorGenerateRequest, EmulatorFinalizeRequest
@@ -599,29 +603,23 @@ async def native_validation_exception_handler(request: Request, exc: RequestVali
return JSONResponse(status_code=422, content={"detail": exc.errors()})
async def _run_native_checklist(event, request: Request):
async def _run_native_checklist(event, request: Request, *, legacy_contract: str = "agent.process-ticket"):
from src.api.utils import agent_helpers
transaction_id = event.transactionId or f"man-{uuid4().hex[:8]}"
payload = event.model_dump(mode="json", by_alias=True)
final_state = await backoffice_runtime.execute_workflow(
"backoffice_checklist",
payload=payload,
transaction_id=transaction_id,
app_state=request.app.state,
metadata={
"tenant_id": "default",
"agent_id": "backoffice_anatel",
"channel": "rest",
"legacy_contract": "agent.process-ticket",
},
)
envelope = backoffice_rest_adapter.from_ticket_event(event, legacy_contract=legacy_contract)
final_state = await backoffice_dispatcher.execute(envelope, app_state=request.app.state)
try:
response_event = agent_helpers.build_cms_response_event(final_state, transaction_id)
return response_event.model_dump(mode="json", by_alias=True)
response_event = agent_helpers.build_cms_response_event(final_state, envelope.transaction_id)
response = response_event.model_dump(mode="json", by_alias=True)
response.setdefault("metadata", {})
response["metadata"]["framework_entrypoint"] = "channel_gateway"
response["metadata"]["source_channel"] = envelope.channel
return response
except Exception:
return {
"transactionId": transaction_id,
"transactionId": envelope.transaction_id,
"framework_native": True,
"framework_entrypoint": "channel_gateway",
"source_channel": envelope.channel,
"current_step": str(final_state.get("current_step")),
"final_response": final_state.get("final_response"),
"error": final_state.get("error"),
@@ -631,17 +629,17 @@ async def _run_native_checklist(event, request: Request):
@app.post("/agent/process-ticket", status_code=status.HTTP_200_OK)
async def native_process_ticket(request: Request, event: TicketRequestEvent):
return await _run_native_checklist(event, request)
return await _run_native_checklist(event, request, legacy_contract="agent.process-ticket")
@app.post("/agent/execute", status_code=status.HTTP_200_OK)
async def native_agent_execute(request: Request, event: TicketRequestEvent):
return await _run_native_checklist(event, request)
return await _run_native_checklist(event, request, legacy_contract="agent.execute")
@app.post("/agent/process-and-stream", status_code=status.HTTP_200_OK)
async def native_process_and_stream(request: Request, event: TicketRequestEvent):
return await _run_native_checklist(event, request)
return await _run_native_checklist(event, request, legacy_contract="agent.process-and-stream")
@app.post("/agent/search-tais-kb", status_code=status.HTTP_200_OK)
@@ -660,31 +658,23 @@ async def native_search_tais_kb(body: dict):
return {"framework_native": True, "result": result}
async def _run_native_emulator(request: Request, event):
transaction_id = event.transactionId
payload = event.model_dump(mode="json", by_alias=True)
final_state = await backoffice_runtime.execute_workflow(
"backoffice_response_emulator",
payload=payload,
transaction_id=transaction_id,
app_state=request.app.state,
metadata={
"tenant_id": "default",
"agent_id": "backoffice_anatel",
"channel": "rest",
"flow_mode": event.flow_mode,
"selected_actions": [a.model_dump(mode="json") for a in event.selected_actions],
"legacy_contract": "case.response-emulator",
},
)
async def _run_native_emulator(request: Request, event, *, legacy_contract: str = "case.response-emulator"):
envelope = backoffice_rest_adapter.from_emulator_event(event, legacy_contract=legacy_contract)
final_state = await backoffice_dispatcher.execute(envelope, app_state=request.app.state)
try:
from src.api.utils.emulator_response_builder import build_emulator_response_event
response_event = build_emulator_response_event(final_state, transaction_id)
return response_event.model_dump(mode="json", by_alias=True)
response_event = build_emulator_response_event(final_state, envelope.transaction_id)
response = response_event.model_dump(mode="json", by_alias=True)
response.setdefault("metadata", {})
response["metadata"]["framework_entrypoint"] = "channel_gateway"
response["metadata"]["source_channel"] = envelope.channel
return response
except Exception:
return {
"transactionId": transaction_id,
"transactionId": envelope.transaction_id,
"framework_native": True,
"framework_entrypoint": "channel_gateway",
"source_channel": envelope.channel,
"current_step": str(final_state.get("current_step")),
"final_response": final_state.get("final_response"),
"error": final_state.get("error"),
@@ -782,6 +772,8 @@ async def native_health_ready():
"workflows": list(backoffice_runtime._graphs.keys()),
"framework_layers": {
"gateway": True,
"channel_gateway_entrypoint": True,
"backoffice_rest_adapter": True,
"identity": True,
"session_repository": settings.SESSION_REPOSITORY_PROVIDER,
"memory_repository": settings.MEMORY_REPOSITORY_PROVIDER,
@@ -802,7 +794,9 @@ async def debug_backoffice_parity():
"legacy_graph_execution": False,
"legacy_router_registration": False,
"forbidden_active_imports": ["legacy_reference_disabled/original_develop/src_agent_graphs", "legacy_reference_disabled/original_develop/src_api_executors"],
"runtime": "app.workflows.backoffice_native_runtime.BackofficeNativeRuntime",
"entrypoint": "app.channels.backoffice_rest_adapter.BackofficeRestChannelAdapter",
"dispatcher": "app.workflows.backoffice_workflow_dispatcher.BackofficeWorkflowDispatcher",
"runtime": "app.workflows.backoffice_workflow_executor.BackofficeWorkflowExecutor",
"domain_package": "src.agent.nodes + src.components.clients + src.agent.local_prompts",
"workflows": {
"backoffice_checklist": [
@@ -814,6 +808,8 @@ async def debug_backoffice_parity():
},
"framework_layers": {
"gateway": True,
"channel_gateway_entrypoint": True,
"backoffice_rest_adapter": True,
"identity": True,
"session_repository": settings.SESSION_REPOSITORY_PROVIDER,
"memory_repository": settings.MEMORY_REPOSITORY_PROVIDER,

View File

@@ -1,750 +1,13 @@
"""Backoffice TIM/ANATEL workflows executed by the framework runtime.
This module is the migration boundary that makes the backoffice **framework-native**:
* domain logic remains in business nodes/services/prompts copied from develop;
* the backend no longer imports or executes ``src.agent.graphs.*``;
* the framework runtime builds/compiles the LangGraph workflows, owns telemetry,
guardrails, judges, supervisor, checkpoint and persistence hooks;
* legacy REST contracts call ``execute_workflow`` instead of legacy executors.
"""
from __future__ import annotations
from typing import Any, Callable
from pathlib import Path
import inspect
import json
import logging
# Compatibility shim. The framework-native name is BackofficeWorkflowExecutor.
# Existing external imports from app.workflows.backoffice_native_runtime keep working,
# but application code should import BackofficeWorkflowExecutor from
# app.workflows.backoffice_workflow_executor.
import yaml
from app.workflows.backoffice_workflow_executor import BackofficeWorkflowExecutor
from langgraph.graph import END, START, StateGraph
# Deprecated alias for backward compatibility only.
BackofficeNativeRuntime = BackofficeWorkflowExecutor
from agent_framework.checkpoints.langgraph_saver import create_langgraph_checkpointer
from agent_framework.guardrails.pipeline import GuardrailPipeline
from agent_framework.guardrails.output_supervisor import OutputSupervisor
from agent_framework.guardrails.rail_action import RailAction
from agent_framework.guardrails.rail_result import RailResult
from agent_framework.judges.judge import JudgePipeline
from agent_framework.supervisor.supervisor import Supervisor
from agent_framework.observability.workflow_events import WorkflowTelemetry
from agent_framework.observability.guardrail_events import GuardrailTelemetry
from agent_framework.observability.judge_events import JudgeTelemetry
from agent_framework.observability.langgraph_telemetry import LangGraphDeepTelemetry
from agent_framework.observability.observer import AgentObserver
from src.agent.state.agent_state import AgentState, create_initial_state, increment_iteration
from src.agent.state.steps import GraphStep
from src.agent.state.steps_emulator import EmulatorGraphStep
import src.agent.nodes as checklist_nodes
from src.agent.nodes.emulator import (
approve_draft_node,
close_case_node,
fetch_case_node,
generate_response_node,
persist_draft_node,
retrieve_history_node,
retrieve_templates_node,
router_node,
start_response_emulation_node,
validate_actions_node,
validate_response_node,
)
logger = logging.getLogger("backoffice.native_runtime")
def _project_root() -> Path:
return Path(__file__).resolve().parents[2]
def _load_yaml(path: str | Path) -> dict[str, Any]:
resolved = Path(path)
if not resolved.is_absolute():
resolved = _project_root() / resolved
if not resolved.exists():
raise FileNotFoundError(f"Configuração não encontrada: {resolved}")
with resolved.open("r", encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
if not isinstance(data, dict):
raise ValueError(f"Configuração YAML inválida: {resolved}")
data["_config_path"] = str(resolved)
return data
def _resolve_agent_profile(agent_id: str = "backoffice_anatel") -> dict[str, Any]:
agents_cfg = _load_yaml("config/agents.yaml")
for agent in agents_cfg.get("agents", []) or []:
if agent.get("agent_id") == agent_id:
profile = dict(agent)
profile["_agents_config_path"] = agents_cfg.get("_config_path")
return profile
raise ValueError(f"agent_id={agent_id!r} não encontrado em config/agents.yaml")
def _build_guardrail_pipeline(*, settings, observer: AgentObserver, guardrails_config: dict[str, Any]) -> GuardrailPipeline:
"""Cria o GuardrailPipeline do framework amarrado ao profile do agente.
O framework local pode ter assinaturas diferentes conforme a versão. Este
helper tenta usar config_path/config/profile quando suportado; em versões
antigas, instancia o pipeline e anexa a configuração ativa para telemetry e
debug, evitando depender apenas do config global.
"""
base_kwargs: dict[str, Any] = {
"observer": observer,
"enable_parallel": bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
"fail_fast": bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
}
config_path = guardrails_config.get("_config_path")
try:
accepted = set(inspect.signature(GuardrailPipeline.__init__).parameters)
except Exception:
accepted = set()
kwargs = dict(base_kwargs)
if "config_path" in accepted:
kwargs["config_path"] = config_path
if "config_file" in accepted:
kwargs["config_file"] = config_path
if "config" in accepted:
kwargs["config"] = guardrails_config
if "profile" in accepted:
kwargs["profile"] = guardrails_config.get("profile") or guardrails_config.get("agent_id")
if "agent_id" in accepted:
kwargs["agent_id"] = guardrails_config.get("agent_id")
try:
pipeline = GuardrailPipeline(**kwargs)
except TypeError:
pipeline = GuardrailPipeline(**base_kwargs)
for method_name in ("load_config", "configure", "set_config", "with_config"):
method = getattr(pipeline, method_name, None)
if callable(method):
try:
method(guardrails_config)
break
except TypeError:
try:
method(config_path)
break
except TypeError:
continue
setattr(pipeline, "active_agent_id", guardrails_config.get("agent_id"))
setattr(pipeline, "active_profile", guardrails_config.get("profile"))
setattr(pipeline, "active_config_path", config_path)
setattr(pipeline, "active_config", guardrails_config)
return pipeline
class NativeOutputGuardrailRail:
code = "NATIVE_OUTPUT_GUARDRAILS"
def __init__(self, pipeline: GuardrailPipeline):
self.pipeline = pipeline
async def evaluate(self, candidate: str, context: dict[str, Any]):
final, decisions = await self.pipeline.run_output(candidate, context)
serialized = [d.model_dump() for d in decisions]
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
if blocked:
first = blocked[0]
code = (getattr(first, "code", "") or "").upper()
action = RailAction.RETRY if code in {"REVPREC", "CMP", "SCO", "GND"} else RailAction.BLOCK
return RailResult(
code=code or self.code,
action=action,
reason=getattr(first, "reason", "Resposta bloqueada por guardrail de saída"),
guidance=getattr(first, "reason", "Regerar resposta seguindo as políticas de saída."),
sanitized_text=final,
metadata={"native_decisions": serialized},
)
if final != candidate:
return RailResult(
code=self.code,
action=RailAction.SANITIZE,
reason="Resposta sanitizada por guardrail de saída.",
sanitized_text=final,
metadata={"native_decisions": serialized},
)
return RailResult(
code=self.code,
action=RailAction.ALLOW,
reason="Resposta aprovada pelos guardrails de saída.",
sanitized_text=final,
metadata={"native_decisions": serialized},
)
class BackofficeNativeRuntime:
"""Framework-owned workflow runtime for the backoffice domain."""
def __init__(self, *, settings, telemetry, analytics, observer: AgentObserver | None = None):
self.settings = settings
self.telemetry = telemetry
self.analytics = analytics
self.observer = observer or AgentObserver(analytics=analytics)
self.agent_profile = _resolve_agent_profile("backoffice_anatel")
self.guardrails_config = _load_yaml(self.agent_profile["guardrails_config_path"])
self.guardrails = _build_guardrail_pipeline(
settings=settings,
observer=self.observer,
guardrails_config=self.guardrails_config,
)
logger.info(
"Backoffice guardrails bound to framework profile agent_id=%s profile=%s config_path=%s input=%s output=%s",
self.guardrails_config.get("agent_id"),
self.guardrails_config.get("profile"),
self.guardrails_config.get("_config_path"),
[r.get("code") for r in self.guardrails_config.get("input", [])],
[r.get("code") for r in self.guardrails_config.get("output", [])],
)
self.output_supervisor_engine = OutputSupervisor(
rails=[NativeOutputGuardrailRail(self.guardrails)],
observer=self.observer,
max_retries=int(getattr(settings, "OUTPUT_SUPERVISOR_MAX_RETRIES", 3)),
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
)
self.judges = JudgePipeline()
self.supervisor = Supervisor()
self.workflow_telemetry = WorkflowTelemetry(telemetry)
self.guardrail_telemetry = GuardrailTelemetry(telemetry)
self.judge_telemetry = JudgeTelemetry(telemetry)
self.langgraph_telemetry = LangGraphDeepTelemetry(telemetry)
self._graphs: dict[str, Any] = {}
def _base_event_payload(self, state: AgentState | None = None, **extra: Any) -> dict[str, Any]:
state = state or {}
metadata = state.get("metadata", {}) or {}
payload = {
"workflow_id": metadata.get("framework_workflow_id"),
"session_id": state.get("session_id") or metadata.get("session_id") or metadata.get("transaction_id"),
"transaction_id": metadata.get("transaction_id"),
"agent_id": metadata.get("agent_id") or self.guardrails_config.get("agent_id"),
"guardrails_profile": metadata.get("guardrails_profile") or self.guardrails_config.get("profile"),
"framework_native": True,
}
payload.update({k: v for k, v in extra.items() if v is not None})
return payload
async def _safe_emit_ic(self, code: str, state: AgentState | None = None, payload: dict[str, Any] | None = None, *, component: str) -> None:
try:
await self.observer.emit_ic(code, self._base_event_payload(state, **(payload or {})), component=component)
except Exception as exc:
logger.debug("Falha ao emitir IC %s: %s", code, exc)
async def _safe_emit_noc(self, code: str, state: AgentState | None = None, payload: dict[str, Any] | None = None, *, component: str) -> None:
try:
normalized = code if str(code).startswith("NOC.") else f"NOC.{str(code).zfill(3)}"
await self.observer.emit_noc(normalized, self._base_event_payload(state, **(payload or {})), component=component)
except Exception as exc:
logger.debug("Falha ao emitir NOC %s: %s", code, exc)
async def _safe_emit_grl(self, code: str, state: AgentState | None = None, payload: dict[str, Any] | None = None, *, component: str) -> None:
try:
normalized = code if str(code).startswith("GRL.") else f"GRL.{str(code).zfill(3)}"
await self.observer.emit_grl(normalized, self._base_event_payload(state, **(payload or {})), component=component)
except Exception as exc:
logger.debug("Falha ao emitir GRL %s: %s", code, exc)
async def _emit_by_code(self, code: str, state: AgentState, payload: dict[str, Any] | None, *, component: str) -> None:
code = str(code or "IC.UNKNOWN")
if code.startswith("NOC."):
await self._safe_emit_noc(code, state, payload, component=component)
elif code.startswith("GRL."):
await self._safe_emit_grl(code, state, payload, component=component)
else:
await self._safe_emit_ic(code, state, payload, component=component)
async def _bridge_legacy_ics(self, state: AgentState, node_name: str) -> None:
"""Reemite IC/NOC/GRL legados do develop pelo AgentObserver do framework.
Os nós originais ainda chamam ``agent_framework.observer.event(...)``.
O coletor legado captura esses eventos; esta ponte pega apenas os novos
eventos desde a última etapa e os publica de forma padronizada no
observer do framework, preservando AGA.*, NOC.* e GRL.* no Langfuse/OCI.
"""
try:
from src.utils.ics_collector import ICsCollector
except Exception:
return
metadata = state.setdefault("metadata", {})
session_id = state.get("session_id") or metadata.get("transaction_id")
try:
events = ICsCollector.get_current(session_id) if session_id else []
except Exception:
events = []
last_idx = int(metadata.get("_framework_ics_bridge_index", 0) or 0)
new_events = events[last_idx:]
if not new_events:
return
metadata["_framework_ics_bridge_index"] = len(events)
bridge_log = metadata.setdefault("framework_ics_bridge", [])
for item in new_events:
code = str(item.get("code") or "IC.UNKNOWN")
event_payload = dict(item.get("metadata") or {})
event_payload.update({
"legacy_bridge": True,
"legacy_type": item.get("type"),
"legacy_description": item.get("description"),
"legacy_timestamp": item.get("timestamp"),
"source_node": node_name,
})
await self._emit_by_code(code, state, event_payload, component=f"backoffice.native_runtime.legacy_bridge.{node_name}")
bridge_log.append({"code": code, "type": item.get("type"), "node": node_name})
def _node(self, name: str, fn: Callable[[AgentState], Any]):
async def _wrapped(state: AgentState) -> AgentState:
async with self.langgraph_telemetry.node(name, state):
await self.telemetry.event(
"backoffice.workflow.node.started",
{"workflow_id": state.get("metadata", {}).get("framework_workflow_id"), "node": name, "session_id": state.get("session_id")},
kind="workflow",
)
await self._safe_emit_ic(
"IC.BACKOFFICE_NODE_STARTED",
state,
{"node": name},
component=f"backoffice.native_runtime.node.{name}",
)
try:
result = await fn(state)
except Exception as exc:
await self._safe_emit_noc(
"NOC.009",
state,
{"node": name, "type": "ERROR", "error_type": type(exc).__name__, "error": str(exc)},
component=f"backoffice.native_runtime.node.{name}",
)
raise
await self._bridge_legacy_ics(result, name)
await self.telemetry.event(
"backoffice.workflow.node.completed",
{"workflow_id": result.get("metadata", {}).get("framework_workflow_id"), "node": name, "session_id": result.get("session_id")},
kind="workflow",
)
await self._safe_emit_ic(
"IC.BACKOFFICE_NODE_COMPLETED",
result,
{"node": name, "has_error": bool(result.get("error"))},
component=f"backoffice.native_runtime.node.{name}",
)
return result
return _wrapped
async def _framework_input_guardrails(self, state: AgentState) -> AgentState:
await self._safe_emit_grl(
"GRL.001",
state,
{"phase": "input", "status": "started", "rails": [r.get("code") for r in self.guardrails_config.get("input", []) if r.get("enabled", True)]},
component="backoffice.native_runtime.guardrails.input",
)
payload = state.get("metadata", {}).get("request_context", {})
serialized = json.dumps(payload, ensure_ascii=False, default=str)
context = {
**state.get("metadata", {}),
"workflow_id": state.get("metadata", {}).get("framework_workflow_id"),
"agent_id": self.guardrails_config.get("agent_id"),
"guardrails_profile": self.guardrails_config.get("profile"),
"guardrails_config_path": self.guardrails_config.get("_config_path"),
"active_input_rails": [r.get("code") for r in self.guardrails_config.get("input", []) if r.get("enabled", True)],
"active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)],
}
sanitized, decisions = await self.guardrails.run_input(serialized, context)
state.setdefault("metadata", {})["framework_input_guardrails"] = [d.model_dump() for d in decisions]
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
await self._safe_emit_grl(
"GRL.002",
state,
{
"phase": "input",
"status": "completed",
"decision_count": len(decisions),
"blocked": bool(blocked),
"codes": [getattr(d, "code", None) for d in decisions],
},
component="backoffice.native_runtime.guardrails.input",
)
if blocked:
first = blocked[0]
state["error"] = {"type": "InputGuardrailBlocked", "message": getattr(first, "reason", "Entrada bloqueada"), "step": "framework_input_guardrails"}
state["final_response"] = sanitized or "Entrada bloqueada por política de segurança."
await self._safe_emit_grl(
"GRL.003",
state,
{"phase": "input", "status": "blocked", "code": getattr(first, "code", None), "reason": getattr(first, "reason", None)},
component="backoffice.native_runtime.guardrails.input",
)
return state
@staticmethod
def _after_input_guardrails(state):
error = state.get("error") or {}
return "blocked" if error.get("type") == "InputGuardrailBlocked" else "continue"
async def _framework_output_supervisor(self, state: AgentState) -> AgentState:
await self._safe_emit_grl(
"GRL.004",
state,
{"phase": "output_supervisor", "status": "started"},
component="backoffice.native_runtime.output_supervisor",
)
candidate = state.get("final_response") or state.get("response") or str(state.get("metadata", {}).get("request_context", {}).get("transactionId", ""))
context = {
**state.get("metadata", {}),
"session_id": state.get("session_id"),
"agent_id": self.guardrails_config.get("agent_id"),
"guardrails_profile": self.guardrails_config.get("profile"),
"guardrails_config_path": self.guardrails_config.get("_config_path"),
"active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)],
}
decision = await self.output_supervisor_engine.evaluate(candidate, context)
if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
final = decision.candidate
elif decision.action == RailAction.HANDOVER:
final = "Encaminhado para continuidade com especialista."
else:
final = decision.fallback_message
state["final_response"] = final
state.setdefault("metadata", {})["framework_output_supervisor"] = {
"action": decision.action.value,
"approved": decision.approved,
"results": [
{"code": r.code, "action": r.action.value, "reason": r.reason, "guidance": r.guidance, "metadata": r.metadata}
for r in decision.results
],
}
await self._safe_emit_grl(
"GRL.005",
state,
{
"phase": "output_supervisor",
"status": "completed",
"action": decision.action.value,
"approved": decision.approved,
"result_codes": [r.code for r in decision.results],
},
component="backoffice.native_runtime.output_supervisor",
)
if decision.action not in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
await self._safe_emit_grl(
"GRL.006",
state,
{"phase": "output_supervisor", "status": "blocked_or_handover", "action": decision.action.value},
component="backoffice.native_runtime.output_supervisor",
)
return state
async def _framework_output_guardrails(self, state: AgentState) -> AgentState:
await self._safe_emit_grl(
"GRL.007",
state,
{"phase": "output", "status": "started", "rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)]},
component="backoffice.native_runtime.guardrails.output",
)
candidate = state.get("final_response") or ""
context = {
**state.get("metadata", {}),
"agent_id": self.guardrails_config.get("agent_id"),
"guardrails_profile": self.guardrails_config.get("profile"),
"guardrails_config_path": self.guardrails_config.get("_config_path"),
"active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)],
}
final, decisions = await self.guardrails.run_output(candidate, context)
state["final_response"] = final
state.setdefault("metadata", {})["framework_output_guardrails"] = [d.model_dump() for d in decisions]
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
await self._safe_emit_grl(
"GRL.008",
state,
{
"phase": "output",
"status": "completed",
"decision_count": len(decisions),
"blocked": bool(blocked),
"sanitized": final != candidate,
"codes": [getattr(d, "code", None) for d in decisions],
},
component="backoffice.native_runtime.guardrails.output",
)
if blocked or final != candidate:
await self._safe_emit_grl(
"GRL.009",
state,
{"phase": "output", "status": "blocked_or_sanitized", "blocked": bool(blocked), "sanitized": final != candidate},
component="backoffice.native_runtime.guardrails.output",
)
return state
async def _framework_judges(self, state: AgentState) -> AgentState:
payload = state.get("metadata", {}).get("request_context", {})
question = json.dumps(payload, ensure_ascii=False, default=str)
answer = state.get("final_response") or ""
results = await self.judges.evaluate_all(question, answer, state.get("metadata", {}))
state.setdefault("metadata", {})["framework_judges"] = [r.model_dump() for r in results]
return state
async def _framework_supervisor_review(self, state: AgentState) -> AgentState:
answer = state.get("final_response") or ""
ok, reviewed = await self.supervisor.review(answer, state.get("metadata", {}))
state["final_response"] = reviewed if ok else reviewed
state.setdefault("metadata", {})["framework_supervisor_review"] = {"approved": ok}
return state
async def _framework_persist(self, state: AgentState) -> AgentState:
await self._safe_emit_ic(
"IC.BACKOFFICE_WORKFLOW_COMPLETED",
state,
{
"current_step": str(state.get("current_step")),
"has_error": bool(state.get("error")),
},
component="backoffice.native_runtime.persist",
)
await self._safe_emit_noc(
"NOC.006",
state,
{
"type": "INFO" if not state.get("error") else "FAILURE",
"status": "Backoffice workflow completed",
"current_step": str(state.get("current_step")),
},
component="backoffice.native_runtime.persist",
)
return state
def _compile(self, workflow_id: str):
if workflow_id == "backoffice_checklist":
return self._build_checklist_graph()
if workflow_id == "backoffice_response_emulator":
return self._build_emulator_graph()
raise ValueError(f"Unknown workflow_id={workflow_id}")
def get_graph(self, workflow_id: str):
graph = self._graphs.get(workflow_id)
if graph is None:
graph = self._compile(workflow_id)
self._graphs[workflow_id] = graph
return graph
async def execute_workflow(self, workflow_id: str, *, payload: dict[str, Any], transaction_id: str | None = None, app_state=None, metadata: dict[str, Any] | None = None) -> AgentState:
transaction_id = transaction_id or payload.get("transactionId") or payload.get("transaction_id") or "backoffice-session"
state = create_initial_state(session_id=transaction_id)
state["metadata"].update(metadata or {})
state["metadata"].update({
"transaction_id": transaction_id,
"request_context": payload,
"framework_workflow_id": workflow_id,
"framework_native": True,
"agent_id": self.guardrails_config.get("agent_id"),
"guardrails_profile": self.guardrails_config.get("profile"),
"guardrails_config_path": self.guardrails_config.get("_config_path"),
"active_input_rails": [r.get("code") for r in self.guardrails_config.get("input", []) if r.get("enabled", True)],
"active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)],
# Progress producer is kept in src.compat.framework_services, not in state,
# so checkpoints remain JSON-stable and integrity hashes do not change.
"_framework_ics_bridge_index": 0,
})
try:
from src.utils.ics_collector import ICsCollector
ICsCollector.start(transaction_id)
except Exception:
pass
await self._safe_emit_noc(
"NOC.001",
state,
{"type": "INFO", "status": "Backoffice workflow started"},
component="backoffice.native_runtime.execute",
)
await self._safe_emit_ic(
"IC.BACKOFFICE_WORKFLOW_STARTED",
state,
{"payload_keys": sorted(list(payload.keys()))},
component="backoffice.native_runtime.execute",
)
graph = self.get_graph(workflow_id)
config = {"configurable": {"thread_id": f"{workflow_id}:{transaction_id}"}}
try:
final_state = await graph.ainvoke(state, config=config)
await self._bridge_legacy_ics(final_state, "workflow_end")
return final_state
except Exception as exc:
await self._safe_emit_noc(
"NOC.009",
state,
{"type": "ERROR", "status": "Backoffice workflow failed", "error_type": type(exc).__name__, "error": str(exc)},
component="backoffice.native_runtime.execute",
)
raise
finally:
try:
final_ref = locals().get("final_state", state)
final_ref.get("metadata", {}).pop("_oci_producer", None)
final_ref.get("metadata", {}).pop("_framework_ics_bridge_index", None)
except Exception:
pass
try:
from src.utils.ics_collector import ICsCollector
ICsCollector.stop(transaction_id)
except Exception:
pass
# -------------------- Checklist workflow --------------------
def _build_checklist_graph(self):
builder = StateGraph(AgentState)
builder.add_node("framework_input_guardrails", self._node("framework_input_guardrails", self._framework_input_guardrails))
builder.add_node("fetch_ticket", self._node("fetch_ticket", self._fetch_ticket))
builder.add_node(GraphStep.VALIDATION, self._node(str(GraphStep.VALIDATION), checklist_nodes.validation_node.validate_ticket))
builder.add_node(GraphStep.BYPASS_RULES, self._node(str(GraphStep.BYPASS_RULES), checklist_nodes.bypass_rules_node.evaluate_bypass_rules))
builder.add_node(GraphStep.CACHE_CHECK, self._node(str(GraphStep.CACHE_CHECK), checklist_nodes.cache_check_node.check_cache_node))
builder.add_node(GraphStep.IMDB_ENRICHMENT, self._node(str(GraphStep.IMDB_ENRICHMENT), checklist_nodes.imdb_enrichment_node.imdb_enrich_ticket))
builder.add_node(GraphStep.IDENTITY_VERIFICATION, self._node(str(GraphStep.IDENTITY_VERIFICATION), checklist_nodes.identity_verification_node.perform_identity_verification))
builder.add_node(GraphStep.SPEECH_ENRICHMENT, self._node(str(GraphStep.SPEECH_ENRICHMENT), checklist_nodes.speech_enrichment_node.enrich_with_speech))
builder.add_node("knowledge_base_enrichment", self._node("knowledge_base_enrichment", checklist_nodes.knowledge_base_enrichment_node.enrich_with_knowledge_base))
builder.add_node(GraphStep.CANCELING_ANALYSIS, self._node(str(GraphStep.CANCELING_ANALYSIS), checklist_nodes.canceling_analysis_node.perform_canceling_analysis))
builder.add_node(GraphStep.TIM_COMPLAINT_ANALYSIS, self._node(str(GraphStep.TIM_COMPLAINT_ANALYSIS), checklist_nodes.tim_complaint_analysis_node.perform_tim_complaint_analysis))
builder.add_node("different_complaint_operator", self._node("different_complaint_operator", checklist_nodes.different_complaint_operator_node.perform_different_operator))
builder.add_node("undefined_complaint_operator", self._node("undefined_complaint_operator", checklist_nodes.undefined_complaint_operator_node.perform_undefined_complaint))
builder.add_node("tim_complaint", self._node("tim_complaint", checklist_nodes.tim_complaint_node.handle_tim_complaint))
builder.add_node(GraphStep.RECLASSIFICATION_ANALYSIS, self._node(str(GraphStep.RECLASSIFICATION_ANALYSIS), checklist_nodes.reclassification_analysis_node.perform_reclassification_analysis))
builder.add_node(GraphStep.TREATMENT_DECISION, self._node(str(GraphStep.TREATMENT_DECISION), checklist_nodes.treatment_decision_node.treatment_decision))
builder.add_node(GraphStep.SIEBEL_SR_OPENING, self._node(str(GraphStep.SIEBEL_SR_OPENING), checklist_nodes.siebel_sr_opening_node.open_siebel_sr))
builder.add_node("framework_output_supervisor", self._node("framework_output_supervisor", self._framework_output_supervisor))
builder.add_node("framework_output_guardrails", self._node("framework_output_guardrails", self._framework_output_guardrails))
builder.add_node("framework_judges", self._node("framework_judges", self._framework_judges))
builder.add_node("framework_supervisor_review", self._node("framework_supervisor_review", self._framework_supervisor_review))
builder.add_node("framework_persist", self._node("framework_persist", self._framework_persist))
builder.add_edge(START, "framework_input_guardrails")
builder.add_conditional_edges("framework_input_guardrails", self._after_input_guardrails, {"blocked": "framework_persist", "continue": "fetch_ticket"})
builder.add_edge("fetch_ticket", GraphStep.VALIDATION)
builder.add_conditional_edges(GraphStep.VALIDATION, checklist_nodes.validation_node.should_continue, {"continue": GraphStep.BYPASS_RULES, "reject": "framework_output_supervisor"})
builder.add_edge(GraphStep.BYPASS_RULES, GraphStep.CACHE_CHECK)
builder.add_conditional_edges(GraphStep.CACHE_CHECK, self._route_after_cache_check, {GraphStep.TREATMENT_DECISION: GraphStep.TREATMENT_DECISION, GraphStep.CANCELING_ANALYSIS: GraphStep.CANCELING_ANALYSIS, GraphStep.IMDB_ENRICHMENT: GraphStep.IMDB_ENRICHMENT})
builder.add_conditional_edges(GraphStep.IMDB_ENRICHMENT, checklist_nodes.imdb_enrichment_node.should_continue, {"continue": GraphStep.IDENTITY_VERIFICATION, "failed": "framework_output_supervisor"})
builder.add_conditional_edges(GraphStep.IDENTITY_VERIFICATION, checklist_nodes.identity_verification_node.route_after_identity_verification, {"proceed": GraphStep.SPEECH_ENRICHMENT, "cancel": GraphStep.SIEBEL_SR_OPENING, "smart_human": GraphStep.TREATMENT_DECISION, "failed": "framework_output_supervisor"})
builder.add_edge(GraphStep.SPEECH_ENRICHMENT, "knowledge_base_enrichment")
builder.add_conditional_edges("knowledge_base_enrichment", self._route_after_knowledge_base, {GraphStep.CANCELING_ANALYSIS: GraphStep.CANCELING_ANALYSIS, GraphStep.TREATMENT_DECISION: GraphStep.TREATMENT_DECISION})
builder.add_conditional_edges(GraphStep.CANCELING_ANALYSIS, self._route_after_canceling, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.TIM_COMPLAINT_ANALYSIS, "finalize": "framework_output_supervisor"})
builder.add_conditional_edges(GraphStep.TIM_COMPLAINT_ANALYSIS, self._route_after_tim_complaint_analysis, {"tim_complaint": "tim_complaint", "different_complaint_operator": "different_complaint_operator", "undefined_complaint_operator": "undefined_complaint_operator", "finalize": "framework_output_supervisor"})
builder.add_edge("tim_complaint", GraphStep.RECLASSIFICATION_ANALYSIS)
builder.add_conditional_edges("different_complaint_operator", self._route_after_operator_check, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.RECLASSIFICATION_ANALYSIS})
builder.add_conditional_edges("undefined_complaint_operator", self._route_after_operator_check, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.RECLASSIFICATION_ANALYSIS})
builder.add_conditional_edges(GraphStep.RECLASSIFICATION_ANALYSIS, self._route_after_reclassification, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.TREATMENT_DECISION, "finalize": "framework_output_supervisor"})
builder.add_edge(GraphStep.TREATMENT_DECISION, GraphStep.SIEBEL_SR_OPENING)
builder.add_edge(GraphStep.SIEBEL_SR_OPENING, "framework_output_supervisor")
builder.add_edge("framework_output_supervisor", "framework_output_guardrails")
builder.add_edge("framework_output_guardrails", "framework_judges")
builder.add_edge("framework_judges", "framework_supervisor_review")
builder.add_edge("framework_supervisor_review", "framework_persist")
builder.add_edge("framework_persist", END)
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
async def _fetch_ticket(self, state: AgentState) -> AgentState:
increment_iteration(state)
return await checklist_nodes.fetch_ticket_node.fetch_ticket_data(state)
@staticmethod
def _route_after_cache_check(state: AgentState) -> str:
if state.get("cache_found") is True:
if state.get("bypass_treatment_validations"):
return GraphStep.TREATMENT_DECISION
return GraphStep.CANCELING_ANALYSIS
return GraphStep.IMDB_ENRICHMENT
@staticmethod
def _route_after_knowledge_base(state: AgentState) -> str:
return GraphStep.TREATMENT_DECISION if state.get("bypass_treatment_validations") else GraphStep.CANCELING_ANALYSIS
@staticmethod
def _route_after_canceling(state: AgentState) -> str:
step = state.get("current_step")
if step == GraphStep.CANCELING_ANALYSIS_CANCEL_TICKET:
return GraphStep.SIEBEL_SR_OPENING
if step == GraphStep.PROCEED_GRAPH:
return GraphStep.PROCEED_GRAPH
return "finalize"
@staticmethod
def _route_after_tim_complaint_analysis(state: AgentState) -> str:
decision = state.get("metadata", {}).get("request_context", {}).get("is_tim_complaint", "")
if decision == "sim":
return "tim_complaint"
if decision == "não":
return "different_complaint_operator"
if decision == "inconclusivo":
return "undefined_complaint_operator"
return "finalize"
@staticmethod
def _route_after_operator_check(state: AgentState) -> str:
context = state.get("metadata", {}).get("request_context", {})
if context.get("forward_complaint"):
return GraphStep.SIEBEL_SR_OPENING
return GraphStep.PROCEED_GRAPH
@staticmethod
def _route_after_reclassification(state: AgentState) -> str:
step = state.get("current_step")
if step == GraphStep.RECLASSIFICATION_ANALYSIS_COMPLETED:
context = state.get("metadata", {}).get("request_context", {})
if context.get("siebel_action") == "reclassificar":
return GraphStep.SIEBEL_SR_OPENING
return GraphStep.PROCEED_GRAPH
return "finalize"
# -------------------- Emulator workflow --------------------
def _build_emulator_graph(self):
builder = StateGraph(AgentState)
builder.add_node("framework_input_guardrails", self._node("framework_input_guardrails", self._framework_input_guardrails))
builder.add_node(EmulatorGraphStep.RESPONSE_EMULATION_START, self._node(str(EmulatorGraphStep.RESPONSE_EMULATION_START), start_response_emulation_node.start_response_emulation))
builder.add_node(EmulatorGraphStep.FETCH_CASE, self._node(str(EmulatorGraphStep.FETCH_CASE), fetch_case_node.fetch_case))
builder.add_node(EmulatorGraphStep.VALIDATE_ACTIONS, self._node(str(EmulatorGraphStep.VALIDATE_ACTIONS), validate_actions_node.validate_actions))
builder.add_node(EmulatorGraphStep.ROUTER_DECISION, self._node(str(EmulatorGraphStep.ROUTER_DECISION), router_node.route))
builder.add_node(EmulatorGraphStep.RETRIEVE_TEMPLATES, self._node(str(EmulatorGraphStep.RETRIEVE_TEMPLATES), retrieve_templates_node.retrieve_templates))
builder.add_node(EmulatorGraphStep.RETRIEVE_HISTORY, self._node(str(EmulatorGraphStep.RETRIEVE_HISTORY), retrieve_history_node.retrieve_history))
builder.add_node(EmulatorGraphStep.GENERATE_RESPONSE, self._node(str(EmulatorGraphStep.GENERATE_RESPONSE), generate_response_node.generate_response))
builder.add_node(EmulatorGraphStep.VALIDATE_RESPONSE, self._node(str(EmulatorGraphStep.VALIDATE_RESPONSE), validate_response_node.validate_response))
builder.add_node(EmulatorGraphStep.PERSIST_DRAFT, self._node(str(EmulatorGraphStep.PERSIST_DRAFT), persist_draft_node.persist_draft))
builder.add_node(EmulatorGraphStep.APPROVE_DRAFT, self._node(str(EmulatorGraphStep.APPROVE_DRAFT), approve_draft_node.approve_draft))
builder.add_node(EmulatorGraphStep.CLOSE_CASE, self._node(str(EmulatorGraphStep.CLOSE_CASE), close_case_node.close_case))
builder.add_node("framework_output_supervisor", self._node("framework_output_supervisor", self._framework_output_supervisor))
builder.add_node("framework_output_guardrails", self._node("framework_output_guardrails", self._framework_output_guardrails))
builder.add_node("framework_judges", self._node("framework_judges", self._framework_judges))
builder.add_node("framework_supervisor_review", self._node("framework_supervisor_review", self._framework_supervisor_review))
builder.add_node("framework_persist", self._node("framework_persist", self._framework_persist))
builder.add_edge(START, "framework_input_guardrails")
builder.add_conditional_edges("framework_input_guardrails", self._after_input_guardrails, {"blocked": "framework_persist", "continue": EmulatorGraphStep.RESPONSE_EMULATION_START})
builder.add_edge(EmulatorGraphStep.RESPONSE_EMULATION_START, EmulatorGraphStep.FETCH_CASE)
builder.add_conditional_edges(EmulatorGraphStep.FETCH_CASE, self._emulator_route_after_fetch, {"generate": EmulatorGraphStep.VALIDATE_ACTIONS, "approve": EmulatorGraphStep.APPROVE_DRAFT, "close": EmulatorGraphStep.CLOSE_CASE, "failed": "framework_output_supervisor"})
builder.add_conditional_edges(EmulatorGraphStep.VALIDATE_ACTIONS, validate_actions_node.should_continue, {"continue": EmulatorGraphStep.ROUTER_DECISION, "failed": "framework_output_supervisor"})
builder.add_conditional_edges(EmulatorGraphStep.ROUTER_DECISION, router_node.next_step_after_router, {EmulatorGraphStep.RETRIEVE_TEMPLATES: EmulatorGraphStep.RETRIEVE_TEMPLATES, EmulatorGraphStep.RETRIEVE_HISTORY: EmulatorGraphStep.RETRIEVE_HISTORY, EmulatorGraphStep.GENERATE_RESPONSE: EmulatorGraphStep.GENERATE_RESPONSE})
builder.add_conditional_edges(EmulatorGraphStep.RETRIEVE_TEMPLATES, router_node.next_step_after_templates, {EmulatorGraphStep.RETRIEVE_HISTORY: EmulatorGraphStep.RETRIEVE_HISTORY, EmulatorGraphStep.GENERATE_RESPONSE: EmulatorGraphStep.GENERATE_RESPONSE})
builder.add_edge(EmulatorGraphStep.RETRIEVE_HISTORY, EmulatorGraphStep.GENERATE_RESPONSE)
builder.add_conditional_edges(EmulatorGraphStep.GENERATE_RESPONSE, generate_response_node.should_continue, {"continue": EmulatorGraphStep.VALIDATE_RESPONSE, "failed": "framework_output_supervisor"})
builder.add_edge(EmulatorGraphStep.VALIDATE_RESPONSE, EmulatorGraphStep.PERSIST_DRAFT)
builder.add_edge(EmulatorGraphStep.PERSIST_DRAFT, "framework_output_supervisor")
builder.add_edge(EmulatorGraphStep.APPROVE_DRAFT, "framework_output_supervisor")
builder.add_edge(EmulatorGraphStep.CLOSE_CASE, "framework_output_supervisor")
builder.add_edge("framework_output_supervisor", "framework_output_guardrails")
builder.add_edge("framework_output_guardrails", "framework_judges")
builder.add_edge("framework_judges", "framework_supervisor_review")
builder.add_edge("framework_supervisor_review", "framework_persist")
builder.add_edge("framework_persist", END)
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
@staticmethod
def _emulator_route_after_fetch(state: AgentState) -> str:
if state.get("error"):
return "failed"
flow_mode = (state.get("metadata") or {}).get("flow_mode")
if flow_mode == "close":
return "close"
if flow_mode == "approve":
return "approve"
return "generate"
__all__ = ["BackofficeWorkflowExecutor", "BackofficeNativeRuntime"]

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
from typing import Any
from app.channels.backoffice_rest_adapter import BackofficeChannelEnvelope, BackofficeRestChannelAdapter
from app.workflows.backoffice_workflow_executor import BackofficeWorkflowExecutor
class BackofficeWorkflowDispatcher:
"""Framework-facing dispatcher for Backoffice operational workflows.
FastAPI routes call this dispatcher with a channel envelope. The dispatcher
first normalizes the envelope through the framework ChannelGateway and only
then dispatches the requested LangGraph workflow. This keeps REST as a
channel adapter concern and prevents routes from coupling directly to a
domain workflow executor.
"""
def __init__(self, *, channel_gateway: Any, executor: BackofficeWorkflowExecutor, telemetry: Any, adapter: BackofficeRestChannelAdapter | None = None):
self.channel_gateway = channel_gateway
self.executor = executor
self.telemetry = telemetry
self.adapter = adapter or BackofficeRestChannelAdapter()
async def execute(self, envelope: BackofficeChannelEnvelope, *, app_state: Any = None) -> dict[str, Any]:
msg = await self.adapter.normalize(self.channel_gateway, envelope)
context = dict(getattr(msg, "context", None) or envelope.gateway_payload())
metadata = {
**(envelope.metadata or {}),
"tenant_id": envelope.tenant_id,
"agent_id": envelope.agent_id,
"channel": envelope.channel,
"normalized_channel": getattr(msg, "channel", envelope.channel),
"channel_id": getattr(msg, "channel_id", None),
"message_id": envelope.message_id,
"user_id": getattr(msg, "user_id", envelope.user_id),
"business_context": envelope.business_context,
"channel_context": context,
"framework_entrypoint": "channel_gateway",
}
await self._event("backoffice.channel.normalized", {
"workflow_id": envelope.workflow_id,
"transaction_id": envelope.transaction_id,
"channel": envelope.channel,
"agent_id": envelope.agent_id,
"legacy_contract": envelope.metadata.get("legacy_contract"),
})
final_state = await self.executor.execute_workflow(
envelope.workflow_id,
payload=envelope.payload,
transaction_id=envelope.transaction_id,
app_state=app_state,
metadata=metadata,
)
await self._event("backoffice.workflow.dispatched", {
"workflow_id": envelope.workflow_id,
"transaction_id": envelope.transaction_id,
"current_step": str(final_state.get("current_step")),
"has_error": bool(final_state.get("error")),
})
return final_state
async def _event(self, name: str, payload: dict[str, Any]) -> None:
try:
await self.telemetry.event(name, payload, kind="workflow")
except TypeError:
await self.telemetry.event(name, payload)
except Exception:
pass

View File

@@ -0,0 +1,779 @@
"""Backoffice TIM/ANATEL workflows executed by the framework runtime.
This module is the migration boundary that makes the backoffice **framework-native**:
* domain logic remains in business nodes/services/prompts copied from develop;
* the backend no longer imports or executes ``src.agent.graphs.*``;
* the Backoffice executor assembles the domain workflows and delegates execution to
the framework-managed LangGraph/checkpoint/telemetry pipeline;
* telemetry, guardrails, judges, supervisor, checkpoint and persistence hooks remain
framework-owned;
* BackofficeWorkflowDispatcher calls ``execute_workflow`` after REST payloads are normalized by the ChannelGateway path.
"""
from __future__ import annotations
from typing import Any, Callable
from pathlib import Path
import inspect
import json
import logging
import yaml
from langgraph.graph import END, START, StateGraph
from agent_framework.checkpoints.langgraph_saver import create_langgraph_checkpointer
from agent_framework.guardrails.pipeline import GuardrailPipeline
from agent_framework.guardrails.output_supervisor import OutputSupervisor
from agent_framework.guardrails.rail_action import RailAction
from agent_framework.guardrails.rail_result import RailResult
from agent_framework.judges.judge import JudgePipeline
from agent_framework.supervisor.supervisor import Supervisor
from agent_framework.observability.workflow_events import WorkflowTelemetry
from agent_framework.observability.guardrail_events import GuardrailTelemetry
from agent_framework.observability.judge_events import JudgeTelemetry
from agent_framework.observability.langgraph_telemetry import LangGraphDeepTelemetry
from agent_framework.observability.observer import AgentObserver
from src.agent.state.agent_state import AgentState, create_initial_state, increment_iteration
from src.agent.state.steps import GraphStep
from src.agent.state.steps_emulator import EmulatorGraphStep
import src.agent.nodes as checklist_nodes
from src.agent.nodes.emulator import (
approve_draft_node,
close_case_node,
fetch_case_node,
generate_response_node,
persist_draft_node,
retrieve_history_node,
retrieve_templates_node,
router_node,
start_response_emulation_node,
validate_actions_node,
validate_response_node,
)
logger = logging.getLogger("backoffice.workflow_executor")
def _project_root() -> Path:
return Path(__file__).resolve().parents[2]
def _load_yaml(path: str | Path) -> dict[str, Any]:
resolved = Path(path)
if not resolved.is_absolute():
resolved = _project_root() / resolved
if not resolved.exists():
raise FileNotFoundError(f"Configuração não encontrada: {resolved}")
with resolved.open("r", encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
if not isinstance(data, dict):
raise ValueError(f"Configuração YAML inválida: {resolved}")
data["_config_path"] = str(resolved)
return data
def _resolve_agent_profile(agent_id: str = "backoffice_anatel") -> dict[str, Any]:
agents_cfg = _load_yaml("config/agents.yaml")
for agent in agents_cfg.get("agents", []) or []:
if agent.get("agent_id") == agent_id:
profile = dict(agent)
profile["_agents_config_path"] = agents_cfg.get("_config_path")
return profile
raise ValueError(f"agent_id={agent_id!r} não encontrado em config/agents.yaml")
def _build_guardrail_pipeline(*, settings, observer: AgentObserver, guardrails_config: dict[str, Any]) -> GuardrailPipeline:
"""Cria o GuardrailPipeline do framework amarrado ao profile do agente.
O framework local pode ter assinaturas diferentes conforme a versão. Este
helper tenta usar config_path/config/profile quando suportado; em versões
antigas, instancia o pipeline e anexa a configuração ativa para telemetry e
debug, evitando depender apenas do config global.
"""
base_kwargs: dict[str, Any] = {
"observer": observer,
"enable_parallel": bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
"fail_fast": bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
}
config_path = guardrails_config.get("_config_path")
try:
accepted = set(inspect.signature(GuardrailPipeline.__init__).parameters)
except Exception:
accepted = set()
kwargs = dict(base_kwargs)
if "config_path" in accepted:
kwargs["config_path"] = config_path
if "config_file" in accepted:
kwargs["config_file"] = config_path
if "config" in accepted:
kwargs["config"] = guardrails_config
if "profile" in accepted:
kwargs["profile"] = guardrails_config.get("profile") or guardrails_config.get("agent_id")
if "agent_id" in accepted:
kwargs["agent_id"] = guardrails_config.get("agent_id")
try:
pipeline = GuardrailPipeline(**kwargs)
except TypeError:
pipeline = GuardrailPipeline(**base_kwargs)
for method_name in ("load_config", "configure", "set_config", "with_config"):
method = getattr(pipeline, method_name, None)
if callable(method):
try:
method(guardrails_config)
break
except TypeError:
try:
method(config_path)
break
except TypeError:
continue
setattr(pipeline, "active_agent_id", guardrails_config.get("agent_id"))
setattr(pipeline, "active_profile", guardrails_config.get("profile"))
setattr(pipeline, "active_config_path", config_path)
setattr(pipeline, "active_config", guardrails_config)
return pipeline
class NativeOutputGuardrailRail:
code = "NATIVE_OUTPUT_GUARDRAILS"
def __init__(self, pipeline: GuardrailPipeline):
self.pipeline = pipeline
async def evaluate(self, candidate: str, context: dict[str, Any]):
final, decisions = await self.pipeline.run_output(candidate, context)
serialized = [d.model_dump() for d in decisions]
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
if blocked:
first = blocked[0]
code = (getattr(first, "code", "") or "").upper()
action = RailAction.RETRY if code in {"REVPREC", "CMP", "SCO", "GND"} else RailAction.BLOCK
return RailResult(
code=code or self.code,
action=action,
reason=getattr(first, "reason", "Resposta bloqueada por guardrail de saída"),
guidance=getattr(first, "reason", "Regerar resposta seguindo as políticas de saída."),
sanitized_text=final,
metadata={"native_decisions": serialized},
)
if final != candidate:
return RailResult(
code=self.code,
action=RailAction.SANITIZE,
reason="Resposta sanitizada por guardrail de saída.",
sanitized_text=final,
metadata={"native_decisions": serialized},
)
return RailResult(
code=self.code,
action=RailAction.ALLOW,
reason="Resposta aprovada pelos guardrails de saída.",
sanitized_text=final,
metadata={"native_decisions": serialized},
)
class BackofficeWorkflowExecutor:
"""Domain executor for Backoffice LangGraph workflows.
This class does not replace the framework LangGraph runtime. It owns the
Backoffice-specific workflow assembly and delegates execution to the
framework-managed LangGraph/checkpoint/telemetry pipeline.
It is not a FastAPI entrypoint. REST routes must enter through
``BackofficeRestChannelAdapter`` and ``BackofficeWorkflowDispatcher`` so the
Backoffice behaves like a corporate channel before the workflow is invoked.
"""
def __init__(self, *, settings, telemetry, analytics, observer: AgentObserver | None = None):
self.settings = settings
self.telemetry = telemetry
self.analytics = analytics
self.observer = observer or AgentObserver(analytics=analytics)
self.agent_profile = _resolve_agent_profile("backoffice_anatel")
self.guardrails_config = _load_yaml(self.agent_profile["guardrails_config_path"])
self.guardrails = _build_guardrail_pipeline(
settings=settings,
observer=self.observer,
guardrails_config=self.guardrails_config,
)
logger.info(
"Backoffice guardrails bound to framework profile agent_id=%s profile=%s config_path=%s input=%s output=%s",
self.guardrails_config.get("agent_id"),
self.guardrails_config.get("profile"),
self.guardrails_config.get("_config_path"),
[r.get("code") for r in self.guardrails_config.get("input", [])],
[r.get("code") for r in self.guardrails_config.get("output", [])],
)
self.output_supervisor_engine = OutputSupervisor(
rails=[NativeOutputGuardrailRail(self.guardrails)],
observer=self.observer,
max_retries=int(getattr(settings, "OUTPUT_SUPERVISOR_MAX_RETRIES", 3)),
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
)
self.judges = JudgePipeline()
self.supervisor = Supervisor()
self.workflow_telemetry = WorkflowTelemetry(telemetry)
self.guardrail_telemetry = GuardrailTelemetry(telemetry)
self.judge_telemetry = JudgeTelemetry(telemetry)
self.langgraph_telemetry = LangGraphDeepTelemetry(telemetry)
self._graphs: dict[str, Any] = {}
def _base_event_payload(self, state: AgentState | None = None, **extra: Any) -> dict[str, Any]:
state = state or {}
metadata = state.get("metadata", {}) or {}
payload = {
"workflow_id": metadata.get("framework_workflow_id"),
"session_id": state.get("session_id") or metadata.get("session_id") or metadata.get("transaction_id"),
"transaction_id": metadata.get("transaction_id"),
"agent_id": metadata.get("agent_id") or self.guardrails_config.get("agent_id"),
"guardrails_profile": metadata.get("guardrails_profile") or self.guardrails_config.get("profile"),
"framework_native": True,
}
payload.update({k: v for k, v in extra.items() if v is not None})
return payload
async def _safe_emit_ic(self, code: str, state: AgentState | None = None, payload: dict[str, Any] | None = None, *, component: str) -> None:
try:
await self.observer.emit_ic(code, self._base_event_payload(state, **(payload or {})), component=component)
except Exception as exc:
logger.debug("Falha ao emitir IC %s: %s", code, exc)
async def _safe_emit_noc(self, code: str, state: AgentState | None = None, payload: dict[str, Any] | None = None, *, component: str) -> None:
try:
normalized = code if str(code).startswith("NOC.") else f"NOC.{str(code).zfill(3)}"
await self.observer.emit_noc(normalized, self._base_event_payload(state, **(payload or {})), component=component)
except Exception as exc:
logger.debug("Falha ao emitir NOC %s: %s", code, exc)
async def _safe_emit_grl(self, code: str, state: AgentState | None = None, payload: dict[str, Any] | None = None, *, component: str) -> None:
try:
normalized = code if str(code).startswith("GRL.") else f"GRL.{str(code).zfill(3)}"
await self.observer.emit_grl(normalized, self._base_event_payload(state, **(payload or {})), component=component)
except Exception as exc:
logger.debug("Falha ao emitir GRL %s: %s", code, exc)
async def _emit_by_code(self, code: str, state: AgentState, payload: dict[str, Any] | None, *, component: str) -> None:
code = str(code or "IC.UNKNOWN")
if code.startswith("NOC."):
await self._safe_emit_noc(code, state, payload, component=component)
elif code.startswith("GRL."):
await self._safe_emit_grl(code, state, payload, component=component)
else:
await self._safe_emit_ic(code, state, payload, component=component)
async def _bridge_legacy_ics(self, state: AgentState | None, node_name: str) -> None:
"""Reemite IC/NOC/GRL legados do develop pelo AgentObserver do framework.
Os nós originais ainda chamam ``agent_framework.observer.event(...)``.
O coletor legado captura esses eventos; esta ponte pega apenas os novos
eventos desde a última etapa e os publica de forma padronizada no
observer do framework, preservando AGA.*, NOC.* e GRL.* no Langfuse/OCI.
Alguns nós legados mutam o state recebido e retornam None. O wrapper
normaliza esse comportamento, mas esta ponte também é defensiva para
não derrubar o grafo por causa da emissão de eventos legados.
"""
if not isinstance(state, dict):
logger.debug("Skipping legacy IC bridge for node=%s because state is %s", node_name, type(state).__name__)
return
try:
from src.utils.ics_collector import ICsCollector
except Exception:
return
metadata = state.setdefault("metadata", {})
session_id = state.get("session_id") or metadata.get("transaction_id")
try:
events = ICsCollector.get_current(session_id) if session_id else []
except Exception:
events = []
last_idx = int(metadata.get("_framework_ics_bridge_index", 0) or 0)
new_events = events[last_idx:]
if not new_events:
return
metadata["_framework_ics_bridge_index"] = len(events)
bridge_log = metadata.setdefault("framework_ics_bridge", [])
for item in new_events:
code = str(item.get("code") or "IC.UNKNOWN")
event_payload = dict(item.get("metadata") or {})
event_payload.update({
"legacy_bridge": True,
"legacy_type": item.get("type"),
"legacy_description": item.get("description"),
"legacy_timestamp": item.get("timestamp"),
"source_node": node_name,
})
await self._emit_by_code(code, state, event_payload, component=f"backoffice.workflow_executor.legacy_bridge.{node_name}")
bridge_log.append({"code": code, "type": item.get("type"), "node": node_name})
def _node(self, name: str, fn: Callable[[AgentState], Any]):
async def _wrapped(state: AgentState) -> AgentState:
async with self.langgraph_telemetry.node(name, state):
await self.telemetry.event(
"backoffice.workflow.node.started",
{"workflow_id": state.get("metadata", {}).get("framework_workflow_id"), "node": name, "session_id": state.get("session_id")},
kind="workflow",
)
await self._safe_emit_ic(
"IC.BACKOFFICE_NODE_STARTED",
state,
{"node": name},
component=f"backoffice.workflow_executor.node.{name}",
)
try:
result = await fn(state)
except Exception as exc:
await self._safe_emit_noc(
"NOC.009",
state,
{"node": name, "type": "ERROR", "error_type": type(exc).__name__, "error": str(exc)},
component=f"backoffice.workflow_executor.node.{name}",
)
raise
# Alguns nós do projeto original seguem o padrão "mutate in place"
# e retornam None. LangGraph precisa receber um state válido para
# continuar; nesses casos preservamos o mesmo objeto de entrada.
if result is None:
logger.debug("Node %s returned None; preserving mutated input state", name)
result = state
elif not isinstance(result, dict):
raise TypeError(f"Backoffice workflow node {name} returned unsupported type: {type(result).__name__}")
result.setdefault("metadata", {})
await self._bridge_legacy_ics(result, name)
await self.telemetry.event(
"backoffice.workflow.node.completed",
{"workflow_id": result.get("metadata", {}).get("framework_workflow_id"), "node": name, "session_id": result.get("session_id")},
kind="workflow",
)
await self._safe_emit_ic(
"IC.BACKOFFICE_NODE_COMPLETED",
result,
{"node": name, "has_error": bool(result.get("error"))},
component=f"backoffice.workflow_executor.node.{name}",
)
return result
return _wrapped
async def _framework_input_guardrails(self, state: AgentState) -> AgentState:
await self._safe_emit_grl(
"GRL.001",
state,
{"phase": "input", "status": "started", "rails": [r.get("code") for r in self.guardrails_config.get("input", []) if r.get("enabled", True)]},
component="backoffice.workflow_executor.guardrails.input",
)
payload = state.get("metadata", {}).get("request_context", {})
serialized = json.dumps(payload, ensure_ascii=False, default=str)
context = {
**state.get("metadata", {}),
"workflow_id": state.get("metadata", {}).get("framework_workflow_id"),
"agent_id": self.guardrails_config.get("agent_id"),
"guardrails_profile": self.guardrails_config.get("profile"),
"guardrails_config_path": self.guardrails_config.get("_config_path"),
"active_input_rails": [r.get("code") for r in self.guardrails_config.get("input", []) if r.get("enabled", True)],
"active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)],
}
sanitized, decisions = await self.guardrails.run_input(serialized, context)
state.setdefault("metadata", {})["framework_input_guardrails"] = [d.model_dump() for d in decisions]
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
await self._safe_emit_grl(
"GRL.002",
state,
{
"phase": "input",
"status": "completed",
"decision_count": len(decisions),
"blocked": bool(blocked),
"codes": [getattr(d, "code", None) for d in decisions],
},
component="backoffice.workflow_executor.guardrails.input",
)
if blocked:
first = blocked[0]
state["error"] = {"type": "InputGuardrailBlocked", "message": getattr(first, "reason", "Entrada bloqueada"), "step": "framework_input_guardrails"}
state["final_response"] = sanitized or "Entrada bloqueada por política de segurança."
await self._safe_emit_grl(
"GRL.003",
state,
{"phase": "input", "status": "blocked", "code": getattr(first, "code", None), "reason": getattr(first, "reason", None)},
component="backoffice.workflow_executor.guardrails.input",
)
return state
@staticmethod
def _after_input_guardrails(state):
error = state.get("error") or {}
return "blocked" if error.get("type") == "InputGuardrailBlocked" else "continue"
async def _framework_output_supervisor(self, state: AgentState) -> AgentState:
await self._safe_emit_grl(
"GRL.004",
state,
{"phase": "output_supervisor", "status": "started"},
component="backoffice.workflow_executor.output_supervisor",
)
candidate = state.get("final_response") or state.get("response") or str(state.get("metadata", {}).get("request_context", {}).get("transactionId", ""))
context = {
**state.get("metadata", {}),
"session_id": state.get("session_id"),
"agent_id": self.guardrails_config.get("agent_id"),
"guardrails_profile": self.guardrails_config.get("profile"),
"guardrails_config_path": self.guardrails_config.get("_config_path"),
"active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)],
}
decision = await self.output_supervisor_engine.evaluate(candidate, context)
if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
final = decision.candidate
elif decision.action == RailAction.HANDOVER:
final = "Encaminhado para continuidade com especialista."
else:
final = decision.fallback_message
state["final_response"] = final
state.setdefault("metadata", {})["framework_output_supervisor"] = {
"action": decision.action.value,
"approved": decision.approved,
"results": [
{"code": r.code, "action": r.action.value, "reason": r.reason, "guidance": r.guidance, "metadata": r.metadata}
for r in decision.results
],
}
await self._safe_emit_grl(
"GRL.005",
state,
{
"phase": "output_supervisor",
"status": "completed",
"action": decision.action.value,
"approved": decision.approved,
"result_codes": [r.code for r in decision.results],
},
component="backoffice.workflow_executor.output_supervisor",
)
if decision.action not in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
await self._safe_emit_grl(
"GRL.006",
state,
{"phase": "output_supervisor", "status": "blocked_or_handover", "action": decision.action.value},
component="backoffice.workflow_executor.output_supervisor",
)
return state
async def _framework_output_guardrails(self, state: AgentState) -> AgentState:
await self._safe_emit_grl(
"GRL.007",
state,
{"phase": "output", "status": "started", "rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)]},
component="backoffice.workflow_executor.guardrails.output",
)
candidate = state.get("final_response") or ""
context = {
**state.get("metadata", {}),
"agent_id": self.guardrails_config.get("agent_id"),
"guardrails_profile": self.guardrails_config.get("profile"),
"guardrails_config_path": self.guardrails_config.get("_config_path"),
"active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)],
}
final, decisions = await self.guardrails.run_output(candidate, context)
state["final_response"] = final
state.setdefault("metadata", {})["framework_output_guardrails"] = [d.model_dump() for d in decisions]
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
await self._safe_emit_grl(
"GRL.008",
state,
{
"phase": "output",
"status": "completed",
"decision_count": len(decisions),
"blocked": bool(blocked),
"sanitized": final != candidate,
"codes": [getattr(d, "code", None) for d in decisions],
},
component="backoffice.workflow_executor.guardrails.output",
)
if blocked or final != candidate:
await self._safe_emit_grl(
"GRL.009",
state,
{"phase": "output", "status": "blocked_or_sanitized", "blocked": bool(blocked), "sanitized": final != candidate},
component="backoffice.workflow_executor.guardrails.output",
)
return state
async def _framework_judges(self, state: AgentState) -> AgentState:
payload = state.get("metadata", {}).get("request_context", {})
question = json.dumps(payload, ensure_ascii=False, default=str)
answer = state.get("final_response") or ""
results = await self.judges.evaluate_all(question, answer, state.get("metadata", {}))
state.setdefault("metadata", {})["framework_judges"] = [r.model_dump() for r in results]
return state
async def _framework_supervisor_review(self, state: AgentState) -> AgentState:
answer = state.get("final_response") or ""
ok, reviewed = await self.supervisor.review(answer, state.get("metadata", {}))
state["final_response"] = reviewed if ok else reviewed
state.setdefault("metadata", {})["framework_supervisor_review"] = {"approved": ok}
return state
async def _framework_persist(self, state: AgentState) -> AgentState:
await self._safe_emit_ic(
"IC.BACKOFFICE_WORKFLOW_COMPLETED",
state,
{
"current_step": str(state.get("current_step")),
"has_error": bool(state.get("error")),
},
component="backoffice.workflow_executor.persist",
)
await self._safe_emit_noc(
"NOC.006",
state,
{
"type": "INFO" if not state.get("error") else "FAILURE",
"status": "Backoffice workflow completed",
"current_step": str(state.get("current_step")),
},
component="backoffice.workflow_executor.persist",
)
return state
def _compile(self, workflow_id: str):
if workflow_id == "backoffice_checklist":
return self._build_checklist_graph()
if workflow_id == "backoffice_response_emulator":
return self._build_emulator_graph()
raise ValueError(f"Unknown workflow_id={workflow_id}")
def get_graph(self, workflow_id: str):
graph = self._graphs.get(workflow_id)
if graph is None:
graph = self._compile(workflow_id)
self._graphs[workflow_id] = graph
return graph
async def execute_workflow(self, workflow_id: str, *, payload: dict[str, Any], transaction_id: str | None = None, app_state=None, metadata: dict[str, Any] | None = None) -> AgentState:
transaction_id = transaction_id or payload.get("transactionId") or payload.get("transaction_id") or "backoffice-session"
state = create_initial_state(session_id=transaction_id)
state["metadata"].update(metadata or {})
state["metadata"].update({
"transaction_id": transaction_id,
"request_context": payload,
"framework_workflow_id": workflow_id,
"framework_native": True,
"agent_id": self.guardrails_config.get("agent_id"),
"guardrails_profile": self.guardrails_config.get("profile"),
"guardrails_config_path": self.guardrails_config.get("_config_path"),
"active_input_rails": [r.get("code") for r in self.guardrails_config.get("input", []) if r.get("enabled", True)],
"active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)],
# Progress producer is kept in src.compat.framework_services, not in state,
# so checkpoints remain JSON-stable and integrity hashes do not change.
"_framework_ics_bridge_index": 0,
})
try:
from src.utils.ics_collector import ICsCollector
ICsCollector.start(transaction_id)
except Exception:
pass
await self._safe_emit_noc(
"NOC.001",
state,
{"type": "INFO", "status": "Backoffice workflow started"},
component="backoffice.workflow_executor.execute",
)
await self._safe_emit_ic(
"IC.BACKOFFICE_WORKFLOW_STARTED",
state,
{"payload_keys": sorted(list(payload.keys()))},
component="backoffice.workflow_executor.execute",
)
graph = self.get_graph(workflow_id)
config = {"configurable": {"thread_id": f"{workflow_id}:{transaction_id}"}}
try:
final_state = await graph.ainvoke(state, config=config)
await self._bridge_legacy_ics(final_state, "workflow_end")
return final_state
except Exception as exc:
await self._safe_emit_noc(
"NOC.009",
state,
{"type": "ERROR", "status": "Backoffice workflow failed", "error_type": type(exc).__name__, "error": str(exc)},
component="backoffice.workflow_executor.execute",
)
raise
finally:
try:
final_ref = locals().get("final_state", state)
final_ref.get("metadata", {}).pop("_oci_producer", None)
final_ref.get("metadata", {}).pop("_framework_ics_bridge_index", None)
except Exception:
pass
try:
from src.utils.ics_collector import ICsCollector
ICsCollector.stop(transaction_id)
except Exception:
pass
# -------------------- Checklist workflow --------------------
def _build_checklist_graph(self):
builder = StateGraph(AgentState)
builder.add_node("framework_input_guardrails", self._node("framework_input_guardrails", self._framework_input_guardrails))
builder.add_node("fetch_ticket", self._node("fetch_ticket", self._fetch_ticket))
builder.add_node(GraphStep.VALIDATION, self._node(str(GraphStep.VALIDATION), checklist_nodes.validation_node.validate_ticket))
builder.add_node(GraphStep.BYPASS_RULES, self._node(str(GraphStep.BYPASS_RULES), checklist_nodes.bypass_rules_node.evaluate_bypass_rules))
builder.add_node(GraphStep.CACHE_CHECK, self._node(str(GraphStep.CACHE_CHECK), checklist_nodes.cache_check_node.check_cache_node))
builder.add_node(GraphStep.IMDB_ENRICHMENT, self._node(str(GraphStep.IMDB_ENRICHMENT), checklist_nodes.imdb_enrichment_node.imdb_enrich_ticket))
builder.add_node(GraphStep.IDENTITY_VERIFICATION, self._node(str(GraphStep.IDENTITY_VERIFICATION), checklist_nodes.identity_verification_node.perform_identity_verification))
builder.add_node(GraphStep.SPEECH_ENRICHMENT, self._node(str(GraphStep.SPEECH_ENRICHMENT), checklist_nodes.speech_enrichment_node.enrich_with_speech))
builder.add_node("knowledge_base_enrichment", self._node("knowledge_base_enrichment", checklist_nodes.knowledge_base_enrichment_node.enrich_with_knowledge_base))
builder.add_node(GraphStep.CANCELING_ANALYSIS, self._node(str(GraphStep.CANCELING_ANALYSIS), checklist_nodes.canceling_analysis_node.perform_canceling_analysis))
builder.add_node(GraphStep.TIM_COMPLAINT_ANALYSIS, self._node(str(GraphStep.TIM_COMPLAINT_ANALYSIS), checklist_nodes.tim_complaint_analysis_node.perform_tim_complaint_analysis))
builder.add_node("different_complaint_operator", self._node("different_complaint_operator", checklist_nodes.different_complaint_operator_node.perform_different_operator))
builder.add_node("undefined_complaint_operator", self._node("undefined_complaint_operator", checklist_nodes.undefined_complaint_operator_node.perform_undefined_complaint))
builder.add_node("tim_complaint", self._node("tim_complaint", checklist_nodes.tim_complaint_node.handle_tim_complaint))
builder.add_node(GraphStep.RECLASSIFICATION_ANALYSIS, self._node(str(GraphStep.RECLASSIFICATION_ANALYSIS), checklist_nodes.reclassification_analysis_node.perform_reclassification_analysis))
builder.add_node(GraphStep.TREATMENT_DECISION, self._node(str(GraphStep.TREATMENT_DECISION), checklist_nodes.treatment_decision_node.treatment_decision))
builder.add_node(GraphStep.SIEBEL_SR_OPENING, self._node(str(GraphStep.SIEBEL_SR_OPENING), checklist_nodes.siebel_sr_opening_node.open_siebel_sr))
builder.add_node("framework_output_supervisor", self._node("framework_output_supervisor", self._framework_output_supervisor))
builder.add_node("framework_output_guardrails", self._node("framework_output_guardrails", self._framework_output_guardrails))
builder.add_node("framework_judges", self._node("framework_judges", self._framework_judges))
builder.add_node("framework_supervisor_review", self._node("framework_supervisor_review", self._framework_supervisor_review))
builder.add_node("framework_persist", self._node("framework_persist", self._framework_persist))
builder.add_edge(START, "framework_input_guardrails")
builder.add_conditional_edges("framework_input_guardrails", self._after_input_guardrails, {"blocked": "framework_persist", "continue": "fetch_ticket"})
builder.add_edge("fetch_ticket", GraphStep.VALIDATION)
builder.add_conditional_edges(GraphStep.VALIDATION, checklist_nodes.validation_node.should_continue, {"continue": GraphStep.BYPASS_RULES, "reject": "framework_output_supervisor"})
builder.add_edge(GraphStep.BYPASS_RULES, GraphStep.CACHE_CHECK)
builder.add_conditional_edges(GraphStep.CACHE_CHECK, self._route_after_cache_check, {GraphStep.TREATMENT_DECISION: GraphStep.TREATMENT_DECISION, GraphStep.CANCELING_ANALYSIS: GraphStep.CANCELING_ANALYSIS, GraphStep.IMDB_ENRICHMENT: GraphStep.IMDB_ENRICHMENT})
builder.add_conditional_edges(GraphStep.IMDB_ENRICHMENT, checklist_nodes.imdb_enrichment_node.should_continue, {"continue": GraphStep.IDENTITY_VERIFICATION, "failed": "framework_output_supervisor"})
builder.add_conditional_edges(GraphStep.IDENTITY_VERIFICATION, checklist_nodes.identity_verification_node.route_after_identity_verification, {"proceed": GraphStep.SPEECH_ENRICHMENT, "cancel": GraphStep.SIEBEL_SR_OPENING, "smart_human": GraphStep.TREATMENT_DECISION, "failed": "framework_output_supervisor"})
builder.add_edge(GraphStep.SPEECH_ENRICHMENT, "knowledge_base_enrichment")
builder.add_conditional_edges("knowledge_base_enrichment", self._route_after_knowledge_base, {GraphStep.CANCELING_ANALYSIS: GraphStep.CANCELING_ANALYSIS, GraphStep.TREATMENT_DECISION: GraphStep.TREATMENT_DECISION})
builder.add_conditional_edges(GraphStep.CANCELING_ANALYSIS, self._route_after_canceling, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.TIM_COMPLAINT_ANALYSIS, "finalize": "framework_output_supervisor"})
builder.add_conditional_edges(GraphStep.TIM_COMPLAINT_ANALYSIS, self._route_after_tim_complaint_analysis, {"tim_complaint": "tim_complaint", "different_complaint_operator": "different_complaint_operator", "undefined_complaint_operator": "undefined_complaint_operator", "finalize": "framework_output_supervisor"})
builder.add_edge("tim_complaint", GraphStep.RECLASSIFICATION_ANALYSIS)
builder.add_conditional_edges("different_complaint_operator", self._route_after_operator_check, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.RECLASSIFICATION_ANALYSIS})
builder.add_conditional_edges("undefined_complaint_operator", self._route_after_operator_check, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.RECLASSIFICATION_ANALYSIS})
builder.add_conditional_edges(GraphStep.RECLASSIFICATION_ANALYSIS, self._route_after_reclassification, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.TREATMENT_DECISION, "finalize": "framework_output_supervisor"})
builder.add_edge(GraphStep.TREATMENT_DECISION, GraphStep.SIEBEL_SR_OPENING)
builder.add_edge(GraphStep.SIEBEL_SR_OPENING, "framework_output_supervisor")
builder.add_edge("framework_output_supervisor", "framework_output_guardrails")
builder.add_edge("framework_output_guardrails", "framework_judges")
builder.add_edge("framework_judges", "framework_supervisor_review")
builder.add_edge("framework_supervisor_review", "framework_persist")
builder.add_edge("framework_persist", END)
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
async def _fetch_ticket(self, state: AgentState) -> AgentState:
increment_iteration(state)
return await checklist_nodes.fetch_ticket_node.fetch_ticket_data(state)
@staticmethod
def _route_after_cache_check(state: AgentState) -> str:
if state.get("cache_found") is True:
if state.get("bypass_treatment_validations"):
return GraphStep.TREATMENT_DECISION
return GraphStep.CANCELING_ANALYSIS
return GraphStep.IMDB_ENRICHMENT
@staticmethod
def _route_after_knowledge_base(state: AgentState) -> str:
return GraphStep.TREATMENT_DECISION if state.get("bypass_treatment_validations") else GraphStep.CANCELING_ANALYSIS
@staticmethod
def _route_after_canceling(state: AgentState) -> str:
step = state.get("current_step")
if step == GraphStep.CANCELING_ANALYSIS_CANCEL_TICKET:
return GraphStep.SIEBEL_SR_OPENING
if step == GraphStep.PROCEED_GRAPH:
return GraphStep.PROCEED_GRAPH
return "finalize"
@staticmethod
def _route_after_tim_complaint_analysis(state: AgentState) -> str:
decision = state.get("metadata", {}).get("request_context", {}).get("is_tim_complaint", "")
if decision == "sim":
return "tim_complaint"
if decision == "não":
return "different_complaint_operator"
if decision == "inconclusivo":
return "undefined_complaint_operator"
return "finalize"
@staticmethod
def _route_after_operator_check(state: AgentState) -> str:
context = state.get("metadata", {}).get("request_context", {})
if context.get("forward_complaint"):
return GraphStep.SIEBEL_SR_OPENING
return GraphStep.PROCEED_GRAPH
@staticmethod
def _route_after_reclassification(state: AgentState) -> str:
step = state.get("current_step")
if step == GraphStep.RECLASSIFICATION_ANALYSIS_COMPLETED:
context = state.get("metadata", {}).get("request_context", {})
if context.get("siebel_action") == "reclassificar":
return GraphStep.SIEBEL_SR_OPENING
return GraphStep.PROCEED_GRAPH
return "finalize"
# -------------------- Emulator workflow --------------------
def _build_emulator_graph(self):
builder = StateGraph(AgentState)
builder.add_node("framework_input_guardrails", self._node("framework_input_guardrails", self._framework_input_guardrails))
builder.add_node(EmulatorGraphStep.RESPONSE_EMULATION_START, self._node(str(EmulatorGraphStep.RESPONSE_EMULATION_START), start_response_emulation_node.start_response_emulation))
builder.add_node(EmulatorGraphStep.FETCH_CASE, self._node(str(EmulatorGraphStep.FETCH_CASE), fetch_case_node.fetch_case))
builder.add_node(EmulatorGraphStep.VALIDATE_ACTIONS, self._node(str(EmulatorGraphStep.VALIDATE_ACTIONS), validate_actions_node.validate_actions))
builder.add_node(EmulatorGraphStep.ROUTER_DECISION, self._node(str(EmulatorGraphStep.ROUTER_DECISION), router_node.route))
builder.add_node(EmulatorGraphStep.RETRIEVE_TEMPLATES, self._node(str(EmulatorGraphStep.RETRIEVE_TEMPLATES), retrieve_templates_node.retrieve_templates))
builder.add_node(EmulatorGraphStep.RETRIEVE_HISTORY, self._node(str(EmulatorGraphStep.RETRIEVE_HISTORY), retrieve_history_node.retrieve_history))
builder.add_node(EmulatorGraphStep.GENERATE_RESPONSE, self._node(str(EmulatorGraphStep.GENERATE_RESPONSE), generate_response_node.generate_response))
builder.add_node(EmulatorGraphStep.VALIDATE_RESPONSE, self._node(str(EmulatorGraphStep.VALIDATE_RESPONSE), validate_response_node.validate_response))
builder.add_node(EmulatorGraphStep.PERSIST_DRAFT, self._node(str(EmulatorGraphStep.PERSIST_DRAFT), persist_draft_node.persist_draft))
builder.add_node(EmulatorGraphStep.APPROVE_DRAFT, self._node(str(EmulatorGraphStep.APPROVE_DRAFT), approve_draft_node.approve_draft))
builder.add_node(EmulatorGraphStep.CLOSE_CASE, self._node(str(EmulatorGraphStep.CLOSE_CASE), close_case_node.close_case))
builder.add_node("framework_output_supervisor", self._node("framework_output_supervisor", self._framework_output_supervisor))
builder.add_node("framework_output_guardrails", self._node("framework_output_guardrails", self._framework_output_guardrails))
builder.add_node("framework_judges", self._node("framework_judges", self._framework_judges))
builder.add_node("framework_supervisor_review", self._node("framework_supervisor_review", self._framework_supervisor_review))
builder.add_node("framework_persist", self._node("framework_persist", self._framework_persist))
builder.add_edge(START, "framework_input_guardrails")
builder.add_conditional_edges("framework_input_guardrails", self._after_input_guardrails, {"blocked": "framework_persist", "continue": EmulatorGraphStep.RESPONSE_EMULATION_START})
builder.add_edge(EmulatorGraphStep.RESPONSE_EMULATION_START, EmulatorGraphStep.FETCH_CASE)
builder.add_conditional_edges(EmulatorGraphStep.FETCH_CASE, self._emulator_route_after_fetch, {"generate": EmulatorGraphStep.VALIDATE_ACTIONS, "approve": EmulatorGraphStep.APPROVE_DRAFT, "close": EmulatorGraphStep.CLOSE_CASE, "failed": "framework_output_supervisor"})
builder.add_conditional_edges(EmulatorGraphStep.VALIDATE_ACTIONS, validate_actions_node.should_continue, {"continue": EmulatorGraphStep.ROUTER_DECISION, "failed": "framework_output_supervisor"})
builder.add_conditional_edges(EmulatorGraphStep.ROUTER_DECISION, router_node.next_step_after_router, {EmulatorGraphStep.RETRIEVE_TEMPLATES: EmulatorGraphStep.RETRIEVE_TEMPLATES, EmulatorGraphStep.RETRIEVE_HISTORY: EmulatorGraphStep.RETRIEVE_HISTORY, EmulatorGraphStep.GENERATE_RESPONSE: EmulatorGraphStep.GENERATE_RESPONSE})
builder.add_conditional_edges(EmulatorGraphStep.RETRIEVE_TEMPLATES, router_node.next_step_after_templates, {EmulatorGraphStep.RETRIEVE_HISTORY: EmulatorGraphStep.RETRIEVE_HISTORY, EmulatorGraphStep.GENERATE_RESPONSE: EmulatorGraphStep.GENERATE_RESPONSE})
builder.add_edge(EmulatorGraphStep.RETRIEVE_HISTORY, EmulatorGraphStep.GENERATE_RESPONSE)
builder.add_conditional_edges(EmulatorGraphStep.GENERATE_RESPONSE, generate_response_node.should_continue, {"continue": EmulatorGraphStep.VALIDATE_RESPONSE, "failed": "framework_output_supervisor"})
builder.add_edge(EmulatorGraphStep.VALIDATE_RESPONSE, EmulatorGraphStep.PERSIST_DRAFT)
builder.add_edge(EmulatorGraphStep.PERSIST_DRAFT, "framework_output_supervisor")
builder.add_edge(EmulatorGraphStep.APPROVE_DRAFT, "framework_output_supervisor")
builder.add_edge(EmulatorGraphStep.CLOSE_CASE, "framework_output_supervisor")
builder.add_edge("framework_output_supervisor", "framework_output_guardrails")
builder.add_edge("framework_output_guardrails", "framework_judges")
builder.add_edge("framework_judges", "framework_supervisor_review")
builder.add_edge("framework_supervisor_review", "framework_persist")
builder.add_edge("framework_persist", END)
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
@staticmethod
def _emulator_route_after_fetch(state: AgentState) -> str:
if state.get("error"):
return "failed"
flow_mode = (state.get("metadata") or {}).get("flow_mode")
if flow_mode == "close":
return "close"
if flow_mode == "approve":
return "approve"
return "generate"