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

14
.env
View File

@@ -214,24 +214,24 @@ GOOGLE_APPLICATION_CREDENTIALS=/Users/cristianohoshikawa/Dropbox/ORACLE/TIM/comp
SPEECH_TIMEOUT=30 SPEECH_TIMEOUT=30
SPEECH_SIMILARITY_THRESHOLD=70 SPEECH_SIMILARITY_THRESHOLD=70
TAIS_DB_USER=USR_ADB_AGNTATEND_W_DEV TAIS_DB_USER=admin
TAIS_DB_PASSWORD=T!M#Esta026! TAIS_DB_PASSWORD=Moniquinha1972
TAIS_DB_DSN="(description= (retry_count=3)(retry_delay=7)(address=(protocol=tcps)(port=1522)(host=10.152.100.72))(connect_data=(service_name=gf9a4a2e79cfeb2_agntatendimentodev_high.adb.oraclecloud.com))(security=(ssl_server_dn_match=no)))" TAIS_DB_DSN=" (description= (retry_count=20)(retry_delay=3)(address=(protocol=tcps)(port=1522)(host=adb.sa-saopaulo-1.oraclecloud.com))(connect_data=(service_name=jy2otyfomimhaoc_oradb23ai_high.adb.oraclecloud.com))(security=(ssl_server_dn_match=yes)))"
TAIS_DB_TIMEOUT=30 TAIS_DB_TIMEOUT=30
TAIS_GENAI_ENDPOINT=https://inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com TAIS_GENAI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
TAIS_GENAI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaa3prjvf7mkvijwn5ng5h6n5ftanbbwqfg6cu44kmffwamhyy267iq TAIS_GENAI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaexpiw4a7dio64mkfv2t273s2hgdl6mgfvvyv7tycalnjlvpvfl3q
TAIS_GENAI_EMBED_MODEL_ID=cohere.embed-multilingual-v3.0 TAIS_GENAI_EMBED_MODEL_ID=cohere.embed-multilingual-v3.0
TAIS_TABLE_CHUNKS=CHUNKS_CHAR_COHERE_3 TAIS_TABLE_CHUNKS=CHUNKS_CHAR_COHERE_3
TAIS_TOP_K=3 TAIS_TOP_K=3
# LLM CLASSIFICATION # LLM CLASSIFICATION
CLASSIFICATION_LLM_MODEL=bo_gptoss20b_dev CLASSIFICATION_LLM_MODEL=openai.gpt-4.1
CLASSIFICATION_LLM_TEMPERATURE=0.3 CLASSIFICATION_LLM_TEMPERATURE=0.3
CLASSIFICATION_LLM_MAX_TOKENS=2000 CLASSIFICATION_LLM_MAX_TOKENS=2000
CLASSIFICATION_LLM_TOP_P=0.9 CLASSIFICATION_LLM_TOP_P=0.9
CLASSIFICATION_LLM_TOP_K=250 CLASSIFICATION_LLM_TOP_K=250
CLASSIFICATION_LARGE_LLM_MODEL=bo_gptoss120b_dev CLASSIFICATION_LARGE_LLM_MODEL=openai.gpt-4.1
CLASSIFICATION_LARGE_LLM_TEMPERATURE=0.3 CLASSIFICATION_LARGE_LLM_TEMPERATURE=0.3
CLASSIFICATION_LARGE_LLM_MAX_TOKENS=4000 CLASSIFICATION_LARGE_LLM_MAX_TOKENS=4000
CLASSIFICATION_LARGE_LLM_TOP_P=0.9 CLASSIFICATION_LARGE_LLM_TOP_P=0.9

View File

@@ -0,0 +1,53 @@
# Patch Summary — Backoffice REST as Channel
## Goal
Adapt the Backoffice project so legacy REST endpoints behave as framework channel entrypoints instead of directly invoking `BackofficeWorkflowExecutor` from FastAPI routes.
## Added
- `app/channels/__init__.py`
- `app/channels/backoffice_rest_adapter.py`
- `app/workflows/backoffice_workflow_dispatcher.py`
- `docs/BACKOFFICE_REST_AS_CHANNEL_ARCHITECTURE.md`
## Modified
- `app/main.py`
- Instantiates `BackofficeRestChannelAdapter` and `BackofficeWorkflowDispatcher`.
- Updates `/agent/*` routes to create a `BackofficeChannelEnvelope` and dispatch through the channel path.
- Updates response metadata with `framework_entrypoint=channel_gateway` and `source_channel=backoffice_rest`.
- Updates readiness/debug metadata to show the channel gateway entrypoint.
- `app/agents/backoffice_agent.py`
- Removes guidance that operational checklist should be called through direct `BackofficeWorkflowExecutor` usage.
- Documents that operational ticket/emulator flows enter through the channel adapter and dispatcher.
- `app/workflows/backoffice_workflow_executor.py`
- Repositioned as the workflow execution engine behind the dispatcher, not the REST entrypoint.
- `src/api/LEGACY_ROUTES_DISABLED.md`
- Updated to describe the new REST → ChannelGateway → dispatcher → workflow path.
## New execution chain
```text
Legacy REST route
→ BackofficeRestChannelAdapter
→ ChannelGateway.normalize(...)
→ BackofficeWorkflowDispatcher
→ BackofficeWorkflowExecutor / LangGraph workflow
→ Response builder preserving legacy contract
```
## Validation done
Python syntax compilation succeeded for the changed Python files:
```text
app/main.py
app/agents/backoffice_agent.py
app/channels/backoffice_rest_adapter.py
app/workflows/backoffice_workflow_dispatcher.py
app/workflows/backoffice_workflow_executor.py
```

View File

@@ -6,7 +6,7 @@ Esta versão substitui a execução direta dos grafos legados por workflows comp
- O framework é dono do motor de workflow/LangGraph, checkpoint, telemetry, guardrails, judges, supervisor e persistência de execução. - O framework é dono do motor de workflow/LangGraph, checkpoint, telemetry, guardrails, judges, supervisor e persistência de execução.
- O backend fornece apenas customizações de domínio: nós, services/clients, prompts, schemas e contratos REST. - O backend fornece apenas customizações de domínio: nós, services/clients, prompts, schemas e contratos REST.
- Os contratos REST antigos continuam existindo, mas agora são adapters finos para `BackofficeNativeRuntime.execute_workflow(...)`. - Os contratos REST antigos continuam existindo, mas agora são adapters finos para `BackofficeWorkflowExecutor.execute_workflow(...)`.
## O que foi removido do caminho ativo ## O que foi removido do caminho ativo
@@ -26,7 +26,7 @@ legacy_reference_disabled/original_develop/
## Runtime ativo ## Runtime ativo
```text ```text
app/workflows/backoffice_native_runtime.py app/workflows/backoffice_workflow_executor.py
``` ```
Esse runtime monta dois workflows com o motor do framework: Esse runtime monta dois workflows com o motor do framework:

Binary file not shown.

View File

@@ -94,7 +94,7 @@ class BackofficeAgent(AgentRuntimeMixin):
"BACKOFFICE_TICKET_CONTEXT_DETECTED", "BACKOFFICE_TICKET_CONTEXT_DETECTED",
normalized_state, 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, "framework_native": True,
"reason": "o agente conversacional não compila nem executa grafos de domínio", "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 A migração framework-native não permite que o agente conversacional
compile/execute ``src.agent.graphs`` diretamente. Os fluxos checklist e compile/execute ``src.agent.graphs`` diretamente. Os fluxos checklist e
response emulator são executados pelo BackofficeNativeRuntime chamado response emulator são executados pelo dispatcher de workflows após
pelas rotas/adapters do backend. normalização pelo canal backoffice_rest/ChannelGateway.
""" """
return { return {
"executed": False, "executed": False,
"error": { "error": {
"type": "DeprecatedDirectGraphExecution", "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.billing.usage_repository import create_usage_repository
from agent_framework.sse.events import SSEHub from agent_framework.sse.events import SSEHub
from app.workflows.agent_graph import AgentWorkflow 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 from app.identity_extraction import enrich_payload_with_text_identity, extract_identity_from_text
logging.basicConfig(level=settings.LOG_LEVEL) logging.basicConfig(level=settings.LOG_LEVEL)
@@ -69,7 +71,9 @@ identity_resolver = IdentityResolver.from_yaml(settings.IDENTITY_CONFIG_PATH)
agent_profiles = AgentProfileRegistry(settings) agent_profiles = AgentProfileRegistry(settings)
sse_hub = SSEHub(settings, telemetry=telemetry) sse_hub = SSEHub(settings, telemetry=telemetry)
workflow = AgentWorkflow(llm, memory, telemetry, analytics, settings, observer=observer, tool_router=tool_router, summary_memory=summary_memory) 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("LLM provider carregado: %s", llm.__class__.__name__)
logger.info("Langfuse habilitado: %s host=%s", telemetry.is_enabled(), settings.LANGFUSE_HOST) 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 # As rotas antigas permanecem como contratos HTTP de compatibilidade, mas agora
# legados e não executam legacy graph package nem legacy executor package. # atuam apenas como REST adapters. Elas traduzem o payload legado para um
# Elas chamam o BackofficeNativeRuntime, que compila os workflows com o motor # BackofficeChannelEnvelope, passam pelo ChannelGateway e só então o dispatcher
# do framework e aplica guardrails, judges, supervisor, checkpoint e telemetry. # aciona o workflow LangGraph registrado para o domínio.
from src.api.schemas.anatel_schemas import TicketRequestEvent from src.api.schemas.anatel_schemas import TicketRequestEvent
from src.api.schemas.anatel_response_emulator_schemas import EmulatorGenerateRequest, EmulatorFinalizeRequest 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()}) 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 from src.api.utils import agent_helpers
transaction_id = event.transactionId or f"man-{uuid4().hex[:8]}" envelope = backoffice_rest_adapter.from_ticket_event(event, legacy_contract=legacy_contract)
payload = event.model_dump(mode="json", by_alias=True) final_state = await backoffice_dispatcher.execute(envelope, app_state=request.app.state)
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",
},
)
try: try:
response_event = agent_helpers.build_cms_response_event(final_state, transaction_id) response_event = agent_helpers.build_cms_response_event(final_state, envelope.transaction_id)
return response_event.model_dump(mode="json", by_alias=True) 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: except Exception:
return { return {
"transactionId": transaction_id, "transactionId": envelope.transaction_id,
"framework_native": True, "framework_native": True,
"framework_entrypoint": "channel_gateway",
"source_channel": envelope.channel,
"current_step": str(final_state.get("current_step")), "current_step": str(final_state.get("current_step")),
"final_response": final_state.get("final_response"), "final_response": final_state.get("final_response"),
"error": final_state.get("error"), "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) @app.post("/agent/process-ticket", status_code=status.HTTP_200_OK)
async def native_process_ticket(request: Request, event: TicketRequestEvent): 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) @app.post("/agent/execute", status_code=status.HTTP_200_OK)
async def native_agent_execute(request: Request, event: TicketRequestEvent): 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) @app.post("/agent/process-and-stream", status_code=status.HTTP_200_OK)
async def native_process_and_stream(request: Request, event: TicketRequestEvent): 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) @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} return {"framework_native": True, "result": result}
async def _run_native_emulator(request: Request, event): async def _run_native_emulator(request: Request, event, *, legacy_contract: str = "case.response-emulator"):
transaction_id = event.transactionId envelope = backoffice_rest_adapter.from_emulator_event(event, legacy_contract=legacy_contract)
payload = event.model_dump(mode="json", by_alias=True) final_state = await backoffice_dispatcher.execute(envelope, app_state=request.app.state)
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",
},
)
try: try:
from src.api.utils.emulator_response_builder import build_emulator_response_event from src.api.utils.emulator_response_builder import build_emulator_response_event
response_event = build_emulator_response_event(final_state, transaction_id) response_event = build_emulator_response_event(final_state, envelope.transaction_id)
return response_event.model_dump(mode="json", by_alias=True) 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: except Exception:
return { return {
"transactionId": transaction_id, "transactionId": envelope.transaction_id,
"framework_native": True, "framework_native": True,
"framework_entrypoint": "channel_gateway",
"source_channel": envelope.channel,
"current_step": str(final_state.get("current_step")), "current_step": str(final_state.get("current_step")),
"final_response": final_state.get("final_response"), "final_response": final_state.get("final_response"),
"error": final_state.get("error"), "error": final_state.get("error"),
@@ -782,6 +772,8 @@ async def native_health_ready():
"workflows": list(backoffice_runtime._graphs.keys()), "workflows": list(backoffice_runtime._graphs.keys()),
"framework_layers": { "framework_layers": {
"gateway": True, "gateway": True,
"channel_gateway_entrypoint": True,
"backoffice_rest_adapter": True,
"identity": True, "identity": True,
"session_repository": settings.SESSION_REPOSITORY_PROVIDER, "session_repository": settings.SESSION_REPOSITORY_PROVIDER,
"memory_repository": settings.MEMORY_REPOSITORY_PROVIDER, "memory_repository": settings.MEMORY_REPOSITORY_PROVIDER,
@@ -802,7 +794,9 @@ async def debug_backoffice_parity():
"legacy_graph_execution": False, "legacy_graph_execution": False,
"legacy_router_registration": False, "legacy_router_registration": False,
"forbidden_active_imports": ["legacy_reference_disabled/original_develop/src_agent_graphs", "legacy_reference_disabled/original_develop/src_api_executors"], "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", "domain_package": "src.agent.nodes + src.components.clients + src.agent.local_prompts",
"workflows": { "workflows": {
"backoffice_checklist": [ "backoffice_checklist": [
@@ -814,6 +808,8 @@ async def debug_backoffice_parity():
}, },
"framework_layers": { "framework_layers": {
"gateway": True, "gateway": True,
"channel_gateway_entrypoint": True,
"backoffice_rest_adapter": True,
"identity": True, "identity": True,
"session_repository": settings.SESSION_REPOSITORY_PROVIDER, "session_repository": settings.SESSION_REPOSITORY_PROVIDER,
"memory_repository": settings.MEMORY_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 __future__ import annotations
from typing import Any, Callable # Compatibility shim. The framework-native name is BackofficeWorkflowExecutor.
from pathlib import Path # Existing external imports from app.workflows.backoffice_native_runtime keep working,
import inspect # but application code should import BackofficeWorkflowExecutor from
import json # app.workflows.backoffice_workflow_executor.
import logging
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 __all__ = ["BackofficeWorkflowExecutor", "BackofficeNativeRuntime"]
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"

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"

View File

@@ -144,3 +144,16 @@
{"ts": "2026-06-13T13:13:33.430808Z", "key": "man-1448d83f", "payload": {"transactionId": "man-1448d83f", "processing": {"status": "processing", "current_step": "reclassification_analysis", "action": "await_response", "note": "[13/06/2026 10:13:27] Chamado recebido, iniciando processamento.\n[13/06/2026 10:13:27] Enriquecendo chamado no IMDB....\n[13/06/2026 10:13:27] Realizando verificação de CPF divergente....\n[13/06/2026 10:13:27] Enriquecendo chamado via Speech Analytics.\n[13/06/2026 10:13:27] Consultando procedimentos na base de conhecimento....\n[13/06/2026 10:13:28] Realizando análise de cancelamento....\n[13/06/2026 10:13:30] Realizando análise de reencaminhamento....\n[13/06/2026 10:13:33] Realizando análise de reclassificação do chamado....\n[13/06/2026 10:13:33] Realizando análise de reclassificação do chamado....", "timestamp": "2026-06-13T13:13:33.430794Z"}}} {"ts": "2026-06-13T13:13:33.430808Z", "key": "man-1448d83f", "payload": {"transactionId": "man-1448d83f", "processing": {"status": "processing", "current_step": "reclassification_analysis", "action": "await_response", "note": "[13/06/2026 10:13:27] Chamado recebido, iniciando processamento.\n[13/06/2026 10:13:27] Enriquecendo chamado no IMDB....\n[13/06/2026 10:13:27] Realizando verificação de CPF divergente....\n[13/06/2026 10:13:27] Enriquecendo chamado via Speech Analytics.\n[13/06/2026 10:13:27] Consultando procedimentos na base de conhecimento....\n[13/06/2026 10:13:28] Realizando análise de cancelamento....\n[13/06/2026 10:13:30] Realizando análise de reencaminhamento....\n[13/06/2026 10:13:33] Realizando análise de reclassificação do chamado....\n[13/06/2026 10:13:33] Realizando análise de reclassificação do chamado....", "timestamp": "2026-06-13T13:13:33.430794Z"}}}
{"ts": "2026-06-13T13:13:37.097443Z", "key": "man-1448d83f", "payload": {"transactionId": "man-1448d83f", "processing": {"status": "processing", "current_step": "siebel_sr_opening", "action": "await_response", "note": "[13/06/2026 10:13:27] Chamado recebido, iniciando processamento.\n[13/06/2026 10:13:27] Enriquecendo chamado no IMDB....\n[13/06/2026 10:13:27] Realizando verificação de CPF divergente....\n[13/06/2026 10:13:27] Enriquecendo chamado via Speech Analytics.\n[13/06/2026 10:13:27] Consultando procedimentos na base de conhecimento....\n[13/06/2026 10:13:28] Realizando análise de cancelamento....\n[13/06/2026 10:13:30] Realizando análise de reencaminhamento....\n[13/06/2026 10:13:33] Realizando análise de reclassificação do chamado....\n[13/06/2026 10:13:33] Realizando análise de reclassificação do chamado....\n[13/06/2026 10:13:37] Abrindo chamado no Siebel....", "timestamp": "2026-06-13T13:13:37.097424Z"}}} {"ts": "2026-06-13T13:13:37.097443Z", "key": "man-1448d83f", "payload": {"transactionId": "man-1448d83f", "processing": {"status": "processing", "current_step": "siebel_sr_opening", "action": "await_response", "note": "[13/06/2026 10:13:27] Chamado recebido, iniciando processamento.\n[13/06/2026 10:13:27] Enriquecendo chamado no IMDB....\n[13/06/2026 10:13:27] Realizando verificação de CPF divergente....\n[13/06/2026 10:13:27] Enriquecendo chamado via Speech Analytics.\n[13/06/2026 10:13:27] Consultando procedimentos na base de conhecimento....\n[13/06/2026 10:13:28] Realizando análise de cancelamento....\n[13/06/2026 10:13:30] Realizando análise de reencaminhamento....\n[13/06/2026 10:13:33] Realizando análise de reclassificação do chamado....\n[13/06/2026 10:13:33] Realizando análise de reclassificação do chamado....\n[13/06/2026 10:13:37] Abrindo chamado no Siebel....", "timestamp": "2026-06-13T13:13:37.097424Z"}}}
{"ts": "2026-06-13T13:13:37.103780Z", "key": "man-1448d83f", "payload": {"transactionId": "man-1448d83f", "processing": {"status": "processing", "current_step": "siebel_sr_opened", "action": "await_response", "note": "[13/06/2026 10:13:27] Chamado recebido, iniciando processamento.\n[13/06/2026 10:13:27] Enriquecendo chamado no IMDB....\n[13/06/2026 10:13:27] Realizando verificação de CPF divergente....\n[13/06/2026 10:13:27] Enriquecendo chamado via Speech Analytics.\n[13/06/2026 10:13:27] Consultando procedimentos na base de conhecimento....\n[13/06/2026 10:13:28] Realizando análise de cancelamento....\n[13/06/2026 10:13:30] Realizando análise de reencaminhamento....\n[13/06/2026 10:13:33] Realizando análise de reclassificação do chamado....\n[13/06/2026 10:13:33] Realizando análise de reclassificação do chamado....\n[13/06/2026 10:13:37] Abrindo chamado no Siebel....\n[13/06/2026 10:13:37] Chamado aberto no Siebel.", "timestamp": "2026-06-13T13:13:37.103766Z"}}} {"ts": "2026-06-13T13:13:37.103780Z", "key": "man-1448d83f", "payload": {"transactionId": "man-1448d83f", "processing": {"status": "processing", "current_step": "siebel_sr_opened", "action": "await_response", "note": "[13/06/2026 10:13:27] Chamado recebido, iniciando processamento.\n[13/06/2026 10:13:27] Enriquecendo chamado no IMDB....\n[13/06/2026 10:13:27] Realizando verificação de CPF divergente....\n[13/06/2026 10:13:27] Enriquecendo chamado via Speech Analytics.\n[13/06/2026 10:13:27] Consultando procedimentos na base de conhecimento....\n[13/06/2026 10:13:28] Realizando análise de cancelamento....\n[13/06/2026 10:13:30] Realizando análise de reencaminhamento....\n[13/06/2026 10:13:33] Realizando análise de reclassificação do chamado....\n[13/06/2026 10:13:33] Realizando análise de reclassificação do chamado....\n[13/06/2026 10:13:37] Abrindo chamado no Siebel....\n[13/06/2026 10:13:37] Chamado aberto no Siebel.", "timestamp": "2026-06-13T13:13:37.103766Z"}}}
{"ts": "2026-06-13T18:36:33.638465Z", "key": "man-e10208c7", "payload": {"transactionId": "man-e10208c7", "processing": {"status": "processing", "current_step": "fetching_ticket", "action": "await_response", "note": "[13/06/2026 15:36:33] Chamado recebido, iniciando processamento.", "timestamp": "2026-06-13T18:36:33.638442Z"}}}
{"ts": "2026-06-13T18:36:33.647743Z", "key": "man-e10208c7", "payload": {"transactionId": "man-e10208c7", "processing": {"status": "processing", "current_step": "validation", "action": "await_response", "note": "[13/06/2026 15:36:33] Chamado recebido, iniciando processamento.", "timestamp": "2026-06-13T18:36:33.647718Z"}}}
{"ts": "2026-06-13T18:36:33.665164Z", "key": "man-e10208c7", "payload": {"transactionId": "man-e10208c7", "processing": {"status": "processing", "current_step": "imdb_enrichment", "action": "await_response", "note": "[13/06/2026 15:36:33] Chamado recebido, iniciando processamento.\n[13/06/2026 15:36:33] Enriquecendo chamado no IMDB....", "timestamp": "2026-06-13T18:36:33.665136Z"}}}
{"ts": "2026-06-13T18:36:37.714001Z", "key": "man-e10208c7", "payload": {"transactionId": "man-e10208c7", "processing": {"status": "processing", "current_step": "identity_verification", "action": "await_response", "note": "[13/06/2026 15:36:33] Chamado recebido, iniciando processamento.\n[13/06/2026 15:36:33] Enriquecendo chamado no IMDB....\n[13/06/2026 15:36:37] Realizando verificação de CPF divergente....", "timestamp": "2026-06-13T18:36:37.713972Z"}}}
{"ts": "2026-06-13T18:36:37.723098Z", "key": "man-e10208c7", "payload": {"transactionId": "man-e10208c7", "processing": {"status": "processing", "current_step": "speech_enrichment", "action": "await_response", "note": "[13/06/2026 15:36:33] Chamado recebido, iniciando processamento.\n[13/06/2026 15:36:33] Enriquecendo chamado no IMDB....\n[13/06/2026 15:36:37] Realizando verificação de CPF divergente....\n[13/06/2026 15:36:37] Enriquecendo chamado via Speech Analytics.", "timestamp": "2026-06-13T18:36:37.723075Z"}}}
{"ts": "2026-06-13T18:36:37.798786Z", "key": "man-e10208c7", "payload": {"transactionId": "man-e10208c7", "processing": {"status": "processing", "current_step": "knowledge_base_enrichment", "action": "await_response", "note": "[13/06/2026 15:36:33] Chamado recebido, iniciando processamento.\n[13/06/2026 15:36:33] Enriquecendo chamado no IMDB....\n[13/06/2026 15:36:37] Realizando verificação de CPF divergente....\n[13/06/2026 15:36:37] Enriquecendo chamado via Speech Analytics.\n[13/06/2026 15:36:37] Consultando procedimentos na base de conhecimento....", "timestamp": "2026-06-13T18:36:37.798757Z"}}}
{"ts": "2026-06-13T18:37:08.751641Z", "key": "man-e10208c7", "payload": {"transactionId": "man-e10208c7", "processing": {"status": "processing", "current_step": "knowledge_base_enrichment_unavailable", "action": "await_response", "note": "[13/06/2026 15:36:33] Chamado recebido, iniciando processamento.\n[13/06/2026 15:36:33] Enriquecendo chamado no IMDB....\n[13/06/2026 15:36:37] Realizando verificação de CPF divergente....\n[13/06/2026 15:36:37] Enriquecendo chamado via Speech Analytics.\n[13/06/2026 15:36:37] Consultando procedimentos na base de conhecimento....\n[13/06/2026 15:37:08] Base de conhecimento consultada - indisponível no momento.", "timestamp": "2026-06-13T18:37:08.751624Z"}}}
{"ts": "2026-06-13T18:37:08.759915Z", "key": "man-e10208c7", "payload": {"transactionId": "man-e10208c7", "processing": {"status": "processing", "current_step": "canceling_analysis", "action": "await_response", "note": "[13/06/2026 15:36:33] Chamado recebido, iniciando processamento.\n[13/06/2026 15:36:33] Enriquecendo chamado no IMDB....\n[13/06/2026 15:36:37] Realizando verificação de CPF divergente....\n[13/06/2026 15:36:37] Enriquecendo chamado via Speech Analytics.\n[13/06/2026 15:36:37] Consultando procedimentos na base de conhecimento....\n[13/06/2026 15:37:08] Base de conhecimento consultada - indisponível no momento.\n[13/06/2026 15:37:08] Realizando análise de cancelamento....", "timestamp": "2026-06-13T18:37:08.759889Z"}}}
{"ts": "2026-06-13T18:37:12.004864Z", "key": "man-e10208c7", "payload": {"transactionId": "man-e10208c7", "processing": {"status": "processing", "current_step": "tim_complaint_analysis", "action": "await_response", "note": "[13/06/2026 15:36:33] Chamado recebido, iniciando processamento.\n[13/06/2026 15:36:33] Enriquecendo chamado no IMDB....\n[13/06/2026 15:36:37] Realizando verificação de CPF divergente....\n[13/06/2026 15:36:37] Enriquecendo chamado via Speech Analytics.\n[13/06/2026 15:36:37] Consultando procedimentos na base de conhecimento....\n[13/06/2026 15:37:08] Base de conhecimento consultada - indisponível no momento.\n[13/06/2026 15:37:08] Realizando análise de cancelamento....\n[13/06/2026 15:37:12] Realizando análise de reencaminhamento....", "timestamp": "2026-06-13T18:37:12.004841Z"}}}
{"ts": "2026-06-13T18:37:15.091211Z", "key": "man-e10208c7", "payload": {"transactionId": "man-e10208c7", "processing": {"status": "processing", "current_step": "reclassification_analysis", "action": "await_response", "note": "[13/06/2026 15:36:33] Chamado recebido, iniciando processamento.\n[13/06/2026 15:36:33] Enriquecendo chamado no IMDB....\n[13/06/2026 15:36:37] Realizando verificação de CPF divergente....\n[13/06/2026 15:36:37] Enriquecendo chamado via Speech Analytics.\n[13/06/2026 15:36:37] Consultando procedimentos na base de conhecimento....\n[13/06/2026 15:37:08] Base de conhecimento consultada - indisponível no momento.\n[13/06/2026 15:37:08] Realizando análise de cancelamento....\n[13/06/2026 15:37:12] Realizando análise de reencaminhamento....\n[13/06/2026 15:37:15] Realizando análise de reclassificação do chamado....", "timestamp": "2026-06-13T18:37:15.091188Z"}}}
{"ts": "2026-06-13T18:37:15.100227Z", "key": "man-e10208c7", "payload": {"transactionId": "man-e10208c7", "processing": {"status": "processing", "current_step": "reclassification_analysis", "action": "await_response", "note": "[13/06/2026 15:36:33] Chamado recebido, iniciando processamento.\n[13/06/2026 15:36:33] Enriquecendo chamado no IMDB....\n[13/06/2026 15:36:37] Realizando verificação de CPF divergente....\n[13/06/2026 15:36:37] Enriquecendo chamado via Speech Analytics.\n[13/06/2026 15:36:37] Consultando procedimentos na base de conhecimento....\n[13/06/2026 15:37:08] Base de conhecimento consultada - indisponível no momento.\n[13/06/2026 15:37:08] Realizando análise de cancelamento....\n[13/06/2026 15:37:12] Realizando análise de reencaminhamento....\n[13/06/2026 15:37:15] Realizando análise de reclassificação do chamado....\n[13/06/2026 15:37:15] Realizando análise de reclassificação do chamado....", "timestamp": "2026-06-13T18:37:15.100198Z"}}}
{"ts": "2026-06-13T18:37:18.928802Z", "key": "man-e10208c7", "payload": {"transactionId": "man-e10208c7", "processing": {"status": "processing", "current_step": "siebel_sr_opening", "action": "await_response", "note": "[13/06/2026 15:36:33] Chamado recebido, iniciando processamento.\n[13/06/2026 15:36:33] Enriquecendo chamado no IMDB....\n[13/06/2026 15:36:37] Realizando verificação de CPF divergente....\n[13/06/2026 15:36:37] Enriquecendo chamado via Speech Analytics.\n[13/06/2026 15:36:37] Consultando procedimentos na base de conhecimento....\n[13/06/2026 15:37:08] Base de conhecimento consultada - indisponível no momento.\n[13/06/2026 15:37:08] Realizando análise de cancelamento....\n[13/06/2026 15:37:12] Realizando análise de reencaminhamento....\n[13/06/2026 15:37:15] Realizando análise de reclassificação do chamado....\n[13/06/2026 15:37:15] Realizando análise de reclassificação do chamado....\n[13/06/2026 15:37:18] Abrindo chamado no Siebel....", "timestamp": "2026-06-13T18:37:18.928779Z"}}}
{"ts": "2026-06-13T18:37:18.937657Z", "key": "man-e10208c7", "payload": {"transactionId": "man-e10208c7", "processing": {"status": "processing", "current_step": "siebel_sr_opened", "action": "await_response", "note": "[13/06/2026 15:36:33] Chamado recebido, iniciando processamento.\n[13/06/2026 15:36:33] Enriquecendo chamado no IMDB....\n[13/06/2026 15:36:37] Realizando verificação de CPF divergente....\n[13/06/2026 15:36:37] Enriquecendo chamado via Speech Analytics.\n[13/06/2026 15:36:37] Consultando procedimentos na base de conhecimento....\n[13/06/2026 15:37:08] Base de conhecimento consultada - indisponível no momento.\n[13/06/2026 15:37:08] Realizando análise de cancelamento....\n[13/06/2026 15:37:12] Realizando análise de reencaminhamento....\n[13/06/2026 15:37:15] Realizando análise de reclassificação do chamado....\n[13/06/2026 15:37:15] Realizando análise de reclassificação do chamado....\n[13/06/2026 15:37:18] Abrindo chamado no Siebel....\n[13/06/2026 15:37:18] Chamado aberto no Siebel.", "timestamp": "2026-06-13T18:37:18.937634Z"}}}

View File

@@ -33,7 +33,7 @@ Esta versão corrige dois pontos do runtime nativo do backoffice:
## Arquivos alterados ## Arquivos alterados
- `app/workflows/backoffice_native_runtime.py` - `app/workflows/backoffice_workflow_executor.py`
- `src/core/config.py` - `src/core/config.py`
- `.env.example` - `.env.example`
- `tools/validate_parity.py` - `tools/validate_parity.py`

View File

@@ -0,0 +1,97 @@
# Backoffice REST as a Framework Channel
This project now treats the TIM/ANATEL Backoffice REST contracts as a corporate channel instead of letting the HTTP routes call a domain workflow executor directly.
## Target execution model
```text
Legacy-compatible REST endpoint
BackofficeRestChannelAdapter
ChannelGateway normalization path
BackofficeWorkflowDispatcher
Backoffice workflow LangGraph
Framework layers: guardrails, supervisor, judges, checkpoint, telemetry, MCP
Legacy-compatible REST response adapter
```
## What changed
### New files
- `app/channels/backoffice_rest_adapter.py`
- Converts legacy Backoffice REST payloads into `BackofficeChannelEnvelope`.
- Preserves the original payload under `request_context`.
- Extracts canonical `business_context` such as `customer_key`, `contract_key`, `interaction_key` and `session_key`.
- Passes the request through `ChannelGateway.normalize(...)` using `channel = backoffice_rest`.
- Falls back to the web adapter shape when the installed framework does not yet have a dedicated `backoffice_rest` adapter.
- `app/workflows/backoffice_workflow_dispatcher.py`
- Receives a normalized channel envelope.
- Emits framework telemetry events for channel normalization and workflow dispatch.
- Invokes the correct operational workflow:
- `backoffice_checklist`
- `backoffice_response_emulator`
### Updated file
- `app/main.py`
- The compatibility routes no longer call `BackofficeWorkflowExecutor.execute_workflow(...)` directly.
- They now call:
```python
BackofficeRestChannelAdapter
BackofficeWorkflowDispatcher
BackofficeWorkflowExecutor
```
The `BackofficeWorkflowExecutor` still owns the compiled LangGraph workflows, but it is now behind the channel/dispatcher boundary rather than being the direct REST entrypoint.
## Why this is architecturally cleaner
The REST routes are now only transport adapters. They preserve the old external contract, but the request enters the same conceptual pipeline used by other framework channels.
This avoids the previous coupling:
```text
REST route → BackofficeWorkflowExecutor
```
and replaces it with:
```text
REST route → channel adapter → ChannelGateway → workflow dispatcher → LangGraph workflow
```
## Compatibility
The existing routes remain available:
- `POST /agent/process-ticket`
- `POST /agent/execute`
- `POST /agent/process-and-stream`
- `POST /case/{transaction_id}/response-emulator/generate`
- `POST /case/{transaction_id}/response-emulator/finalize`
The response builders are preserved, so external clients should not need to change their payload or response handling.
## Runtime notes
The metadata added to workflow state now includes:
```text
framework_entrypoint = channel_gateway
channel = backoffice_rest
normalized_channel = backoffice_rest or web fallback
business_context = canonical business keys
channel_context = normalized gateway context
```
This makes Langfuse/telemetry easier to interpret because Backoffice REST is visible as a channel entrypoint, not as an ad-hoc executor call.

View File

@@ -9,7 +9,7 @@ A versão foi refatorada para que o backoffice não execute mais grafos LangGrap
O backend ativo usa: O backend ativo usa:
- `app/main.py` como camada de rotas/adapters. - `app/main.py` como camada de rotas/adapters.
- `app/workflows/backoffice_native_runtime.py` como runtime de workflows do framework. - `app/workflows/backoffice_workflow_executor.py` como executor de workflows de domínio sobre o runtime LangGraph do framework.
- `src/agent/nodes/*` como nós de domínio. - `src/agent/nodes/*` como nós de domínio.
- `src/components/clients/*` como services/clients de domínio. - `src/components/clients/*` como services/clients de domínio.
- `src/agent/local_prompts/*` como prompts de domínio. - `src/agent/local_prompts/*` como prompts de domínio.
@@ -75,7 +75,7 @@ Agora:
```text ```text
app/main.py chama backoffice_runtime.execute_workflow app/main.py chama backoffice_runtime.execute_workflow
BackofficeNativeRuntime compila os workflows BackofficeWorkflowExecutor compila os workflows
os nós originais são apenas plugins de domínio os nós originais são apenas plugins de domínio
guardrails/judges/supervisor/checkpoint/telemetry entram no runtime guardrails/judges/supervisor/checkpoint/telemetry entram no runtime
``` ```

View File

@@ -1,47 +1,67 @@
from __future__ import annotations # /compass_backoffice/legacy_backend/
# uvicorn mock_imdb_server:app --port 8011
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
app = FastAPI(title="Mock IMDB TIM", version="1.0.0") app = FastAPI(title="Mock IMDB TIM")
@app.get("/access/v1/info")
def _payload(msisdn: str | None = None, cpf_cnpj: str | None = None): async def get_access_info(request: Request):
return { return {
"msisdn": msisdn or "62981152324", "cpfCnpj": request.query_params.get("cpfCnpj") or "06252533106",
"cpfCnpj": cpf_cnpj or "06252533106", "msisdn": request.query_params.get("msisdn") or "62981152324",
"socialSecNo": cpf_cnpj or "06252533106",
"plan": { "plan": {
"Type": "POS_PAGO", "Type": "POS_PAGO",
"name": "TIM Black Família 50GB", "name": "TIM Black Família 50GB"
"commercialName": "TIM Black Família 50GB",
}, },
"statusType": "ACTIVE",
"statusDescription": "Cliente ativo",
"customer": { "customer": {
"segment": "consumer", "segment": "consumer",
"status": "active", "status": "active",
"contumazCustomer": False, "contumazCustomer": False,
"odcCustomer": False, "odcCustomer": False
}, },
"contracts": [
{
"contractId": "mock-contract-001",
"status": "active",
"product": "Celular Pós-pago"
}
]
} }
@app.get("/health")
async def health():
return {"status": "ok", "service": "mock_imdb"}
@app.get("/access/v1/info")
async def get_access_info(request: Request):
return _payload(
msisdn=request.query_params.get("msisdn"),
cpf_cnpj=request.query_params.get("cpfCnpj") or request.query_params.get("cpf_cnpj"),
)
@app.get("/access/v1/info/{msisdn}") @app.get("/access/v1/info/{msisdn}")
async def get_access_info_by_msisdn(msisdn: str, request: Request): async def get_access_info_by_msisdn(msisdn: str):
return _payload( return {
msisdn=msisdn, "msisdn": msisdn,
cpf_cnpj=request.query_params.get("cpfCnpj") or request.query_params.get("cpf_cnpj"), "cpfCnpj": "06252533106",
) "plan": {
"Type": "POS_PAGO",
"name": "TIM Black Família 50GB"
},
"customer": {
"segment": "consumer",
"status": "active",
"contumazCustomer": False,
"odcCustomer": False
}
}
from uuid import uuid4
@app.post("/customers/v1/backOfficeSRopening")
async def backoffice_sr_opening(payload: dict):
protocol = f"SR-MOCK-{uuid4().hex[:8].upper()}"
return {
"status": "success",
"interactionProtocol": protocol,
"crmProtocol": protocol,
"fieldsToUpdate": {
"status": "Aberto",
"reason": "Mock Siebel SR Opening"
},
"case_response": {
"message": "Chamado Siebel aberto com sucesso no mock"
},
"transitions": []
}

View File

@@ -2,4 +2,4 @@
The original `src/agent/graphs` package was moved to `legacy_reference_disabled/original_develop/src_agent_graphs`. The original `src/agent/graphs` package was moved to `legacy_reference_disabled/original_develop/src_agent_graphs`.
It is intentionally not importable by the active backend. The active backend builds and executes workflows through `app.workflows.backoffice_native_runtime.BackofficeNativeRuntime`, which uses the framework runtime and the original domain nodes/services/prompts as customizations. It is intentionally not importable by the active backend. The active backend builds and executes workflows through `app.workflows.backoffice_workflow_executor.BackofficeWorkflowExecutor`, which uses the framework runtime and the original domain nodes/services/prompts as customizations.

Some files were not shown because too many files have changed in this diff Show More