mirror of
https://github.com/hoshikawa2/compass_backoffice.git
synced 2026-07-09 22:04:20 +00:00
bugfixes
This commit is contained in:
14
.env
14
.env
@@ -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
|
||||||
|
|||||||
53
BACKOFFICE_REST_CHANNEL_PATCH_SUMMARY.md
Normal file
53
BACKOFFICE_REST_CHANNEL_PATCH_SUMMARY.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
10
app/channels/__init__.py
Normal 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"]
|
||||||
BIN
app/channels/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
app/channels/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/channels/__pycache__/backoffice_rest_adapter.cpython-313.pyc
Normal file
BIN
app/channels/__pycache__/backoffice_rest_adapter.cpython-313.pyc
Normal file
Binary file not shown.
192
app/channels/backoffice_rest_adapter.py
Normal file
192
app/channels/backoffice_rest_adapter.py
Normal 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"
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
94
app/main.py
94
app/main.py
@@ -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,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
|
||||||
|
|||||||
69
app/workflows/backoffice_workflow_dispatcher.py
Normal file
69
app/workflows/backoffice_workflow_dispatcher.py
Normal 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
|
||||||
779
app/workflows/backoffice_workflow_executor.py
Normal file
779
app/workflows/backoffice_workflow_executor.py
Normal 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"
|
||||||
@@ -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"}}}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
97
docs/BACKOFFICE_REST_AS_CHANNEL_ARCHITECTURE.md
Normal file
97
docs/BACKOFFICE_REST_AS_CHANNEL_ARCHITECTURE.md
Normal 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.
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
BIN
legacy_backend/__pycache__/mock_imdb_server.cpython-313.pyc
Normal file
BIN
legacy_backend/__pycache__/mock_imdb_server.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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": []
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user