first commit

This commit is contained in:
2026-06-13 08:23:21 -03:00
commit 89c23fb0ed
439 changed files with 32801 additions and 0 deletions

BIN
src/api/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,5 @@
# Disabled legacy REST/executor layer
The original route and executor modules were moved to `legacy_reference_disabled/original_develop/` and are kept only for audit/reference.
Active endpoints are implemented in `app/main.py` as thin adapters that call `BackofficeNativeRuntime.execute_workflow(...)`.

1
src/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API Layer - FastAPI application and routes."""

Binary file not shown.

View File

View File

@@ -0,0 +1,58 @@
import uuid
from datetime import datetime, timezone
from src.api.schemas.anatel_schemas import TicketRequestEvent
from src.core.config import settings
from src.core.logging import (
set_conversation_start_ts,
set_request_id,
set_requesting_agent,
set_session_id,
set_source_channel,
set_trace_id,
set_user_id,
)
def set_ticket_log_context(event: TicketRequestEvent, transaction_id: str = None) -> None:
"""
Populates logging contextvars from a TicketRequestEvent.
Centralised so both the FastAPI dependency and the OCI streaming
worker call the same logic. Covers correlation IDs (request/session/
trace/user) plus the conversation-context fields required by the
OCI Logging policy (source_channel, conversation_start_time_ts,
requesting_agent).
"""
transaction_id = transaction_id or event.transactionId
origin = event.origin
submitted_by = origin.submittedBy if origin else None
set_request_id(str(uuid.uuid4()))
set_session_id(transaction_id)
set_trace_id(transaction_id)
set_user_id(submitted_by.userId if submitted_by else "")
set_requesting_agent(settings.AGENT_TYPE)
if origin and origin.sourceSystem:
set_source_channel(origin.sourceSystem)
set_conversation_start_ts(datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"))
def inject_log_context(event: TicketRequestEvent) -> TicketRequestEvent:
"""FastAPI dependency: calls set_ticket_log_context before entering the route handler."""
set_ticket_log_context(event)
return event
def set_emulator_log_context(transaction_id: str, user_id: str = "cms") -> None:
"""Populates logging contextvars for emulator REST endpoints."""
set_request_id(str(uuid.uuid4()))
set_session_id(transaction_id)
set_trace_id(transaction_id)
set_user_id(user_id)
set_requesting_agent(settings.AGENT_TYPE)
set_source_channel("cms")
set_conversation_start_ts(
datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
)

View File

@@ -0,0 +1,11 @@
"""
API middleware components.
"""
from src.api.middleware.logging import LoggingMiddleware
from src.api.middleware.error_handler import ErrorHandlerMiddleware
__all__ = [
"LoggingMiddleware",
"ErrorHandlerMiddleware",
]

Binary file not shown.

View File

@@ -0,0 +1,151 @@
"""
Error handling middleware for FastAPI.
This middleware provides centralized error handling for all exceptions,
ensuring consistent error responses across the API.
"""
from typing import Callable
from fastapi import Request, status
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from starlette.middleware.base import BaseHTTPMiddleware
from src.core.exceptions import AgentException
from src.core.logging import get_logger, get_request_id
logger = get_logger(__name__)
class ErrorHandlerMiddleware(BaseHTTPMiddleware):
"""
Middleware to handle exceptions and return structured error responses.
Handles:
- ValidationError (422): Invalid request payload
- AgentException and subclasses (custom status): Business logic errors
- Exception (500): Unexpected errors
"""
async def dispatch(self, request: Request, call_next: Callable) -> JSONResponse:
"""
Process request and handle any exceptions.
Args:
request: Incoming HTTP request
call_next: Next middleware/handler in chain
Returns:
HTTP response or error response
"""
try:
response = await call_next(request)
return response
except ValidationError as exc:
return await self._handle_validation_error(request, exc)
except AgentException as exc:
return await self._handle_agent_exception(request, exc)
except Exception as exc:
return await self._handle_unexpected_error(request, exc)
async def _handle_validation_error(
self,
request: Request,
exc: ValidationError
) -> JSONResponse:
logger.warning(
"Validation error",
extra={
"path": request.url.path,
"errors": exc.errors(),
}
)
error_messages = []
for err in exc.errors():
loc = " -> ".join([str(l) for l in err.get("loc", [])])
msg = err.get("msg", "Invalid field")
error_messages.append({
"code": "FIELD_ERROR",
"text": f"{loc}: {msg}"
})
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
"title": "validation error",
"status": 400,
"correlation_id": get_request_id(),
"detail": {
"messages": error_messages
}
}
)
async def _handle_agent_exception(
self,
request: Request,
exc: AgentException
) -> JSONResponse:
error_type = type(exc).__name__
if exc.status_code >= 500:
logger.error(
f"Agent error: {error_type}",
extra={
"path": request.url.path,
"error": exc.message,
"error_type": error_type,
},
exc_info=True
)
else:
logger.warning(
f"Agent error: {error_type}",
extra={
"path": request.url.path,
"error": exc.message,
"error_type": error_type,
}
)
return JSONResponse(
status_code=exc.status_code,
content={
"title": "execution error" if exc.status_code < 500 else "system error",
"status": exc.status_code,
"correlation_id": get_request_id(),
"detail": {
"messages": [{"code": error_type.upper(), "text": exc.message}]
}
}
)
async def _handle_unexpected_error(
self,
request: Request,
exc: Exception
) -> JSONResponse:
logger.exception(
"Unexpected error",
extra={
"path": request.url.path,
"error": str(exc),
"error_type": type(exc).__name__,
},
exc_info=True
)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"title": "system error",
"status": 500,
"correlation_id": get_request_id(),
"detail": {
"messages": [{"code": "INTERNAL_SERVER_ERROR", "text": "An unexpected error occurred"}]
}
}
)

View File

@@ -0,0 +1,56 @@
"""
Logging middleware for FastAPI.
Wraps each request in a ``log_operation("http_request")`` so the
started/success/failed pattern from the OCI Logging policy is enforced
uniformly, and clears all contextvars on the way out to avoid leaking
correlation IDs between requests.
"""
import uuid
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from src.core.logging import (
clear_context,
get_logger,
log_operation,
set_request_id,
)
logger = get_logger(__name__)
class LoggingMiddleware(BaseHTTPMiddleware):
"""
Generates a request_id, populates the logging contextvar, and emits
start/success/failed logs via ``log_operation``. Context is cleared on
exit so subsequent requests start from a clean slate.
"""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
request_id = str(uuid.uuid4())
request.state.request_id = request_id
set_request_id(request_id)
method = request.method
path = request.url.path
client_host = request.client.host if request.client else None
try:
async with log_operation(
"http_request",
component="api",
logger=logger,
method=method,
path=path,
client_host=client_host,
user_agent=request.headers.get("user-agent"),
) as op:
response = await call_next(request)
op.add_field("status_code", response.status_code)
response.headers["X-Request-ID"] = request_id
return response
finally:
clear_context()

View File

@@ -0,0 +1,12 @@
"""
API schemas for request and response validation.
"""
from src.api.schemas.request import AgentRequest
from src.api.schemas.response import AgentResponse, HealthResponse
__all__ = [
"AgentRequest",
"AgentResponse",
"HealthResponse",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,16 @@
from pydantic import BaseModel, Field
from typing import Optional
class AbrtRequest(BaseModel):
socialSecNo: str = Field(..., description="Customer's social security number (CPF)")
msisdn: str = Field(..., description="Customer's MSISDN")
flagPre: bool = Field(True, description="Indicates prepaid query")
flagPos: bool = Field(True, description="Indicates postpaid query")
class AbrtResponse(BaseModel):
status: Optional[str] = Field(None, description="Status da consulta")
company: Optional[str] = Field(None, description="Operadora do acesso")
active: Optional[bool] = Field(None, description="Indica se o acesso está ativo")
planTypeId: Optional[str] = Field(None, description="Tipo de plano (0=pos, 1=pre)")

View File

@@ -0,0 +1,165 @@
"""Schemas for the Response Emulator flow.
Modeled after the CMS "Tarefas Executadas" spreadsheet — the hierarchical
catalog the human operator fills in after handling an Anatel complaint.
A selected action is one of two variants discriminated by `type`:
PredefinedAction (type="predefined") — catalog item with an answer tree
└─ answers: { <question_id>: AnswerNode }
AnswerNode (leaf) = { "value": <primitive> }
AnswerNode (branch) = { "value": "<option_id>",
"<option_id>": { <sub_question_id>: AnswerNode, ... } }
When a chosen option carries a sub-form, the sub-answers live under a
key whose name matches the chosen `value`. Leaves only have `value`.
CustomAction (type="custom") — operator's free-form note carrying both
context about what was done and steering hints for the LLM.
"""
from typing import Annotated, Dict, Literal, Optional, Union
from pydantic import BaseModel, ConfigDict, Field, model_validator
AnswerValue = Union[str, int, float, bool, None]
class AnswerNode(BaseModel):
"""One node in the operator's answer tree.
- Leaf: only `value` is set.
- Branch: `value` names the chosen option AND a sibling key with that
same name holds the sub-answers (dict of name → AnswerNode).
Sub-answers are kept loose (`extra='allow'`) because their
keys are dynamic (the option's id).
"""
value: AnswerValue = None
model_config = ConfigDict(extra="allow")
# --- Operator-submitted actions ---
class PredefinedAction(BaseModel):
"""Catalog-listed task picked and filled by the operator in CMS."""
type: Literal["predefined"]
action_id: str
action_title: str
answers: Dict[str, AnswerNode] = Field(default_factory=dict)
class CustomAction(BaseModel):
"""Operator's free-form note. Carries factual context AND steering hints."""
type: Literal["custom"]
action_title: Optional[str] = None
custom_text: str = Field(..., min_length=1)
SelectedAction = Annotated[
Union[PredefinedAction, CustomAction],
Field(discriminator="type"),
]
# --- Emulator event envelope ---
EmulationType = Literal["first_response", "regenerate"]
FlowMode = Literal["generate", "approve", "close"]
EmulatorStatus = Literal[
# In-flight sentinels — set by `start_response_emulation_node._flip_status_in_db`
# so the simulator's polling sees movement while the graph runs.
"processing",
"processing_regeneration",
# Terminal outcomes published by the emulator's terminal TicketResponseEvent.
"awaiting_review",
"approved",
"done",
"failed",
]
class OperatorFeedback(BaseModel):
"""Operator's comment when rejecting a generated response."""
comment: str
class ResponseEmulatorRequestEvent(BaseModel):
"""Internal contract between the REST routes / streaming consumer and
`emulator_graph`. The CMS publishes one of these (wrapped in
StreamEnvelope) per operator action.
"""
transactionId: str
type: EmulationType
flow_mode: FlowMode
selected_actions: list[SelectedAction]
previous_response: Optional[str] = None
feedback: Optional[OperatorFeedback] = None
@model_validator(mode="after")
def _require_regenerate_fields(self) -> "ResponseEmulatorRequestEvent":
if self.type == "regenerate":
if not self.previous_response or not self.feedback:
raise ValueError(
"type='regenerate' requires `previous_response` and `feedback` in the payload"
)
return self
# --- REST request bodies ---
class EmulatorGenerateRequest(BaseModel):
"""Body for POST /case/{transactionId}/response-emulator/generate."""
transactionId: str
action: Literal["generate", "regenerate"]
selected_actions: Optional[list[SelectedAction]] = None
operator_instructions: Optional[str] = None
@model_validator(mode="after")
def _validate_action_fields(self) -> "EmulatorGenerateRequest":
if self.action == "generate":
if not self.selected_actions:
raise ValueError("`selected_actions` is required when action='generate'")
else:
if not (self.operator_instructions or "").strip():
raise ValueError("`operator_instructions` is required when action='regenerate'")
return self
class EmulatorFinalizeRequest(BaseModel):
"""Body for POST /case/{transactionId}/response-emulator/finalize."""
transactionId: str
action: Literal["approve", "close"]
# --- Status / transitions ---
TransitionEvent = Literal["generated", "regenerated", "approved", "closed"]
class Transition(BaseModel):
event: TransitionEvent
at: str
from_status: Optional[str] = None
to_status: str
attempt: Optional[int] = None
actor: Optional[str] = None
class EmulatorStatusResponse(BaseModel):
"""Response body for GET /case/{transactionId}/response-emulator."""
transactionId: str
status: Optional[EmulatorStatus] = None
current_step: Optional[str] = None
case_response: Optional[str] = None
validation: Optional[dict] = None
selected_actions_count: int = 0
regenerate_count: int = 0
transitions: list[Transition] = Field(default_factory=list)
last_updated_at: Optional[str] = None

View File

@@ -0,0 +1,313 @@
from pydantic import BaseModel, Field, field_validator
from typing import List, Literal, Optional, Any, Dict, Union
from datetime import datetime
from enum import Enum
from src.api.schemas.anatel_response_emulator_schemas import ResponseEmulatorRequestEvent
# --- Validation / Error schemas ---
class ReasonCode(str, Enum):
MISSING_CPF_CNPJ = "MISSING_CPF_CNPJ"
MISSING_MOTIVE = "MISSING_MOTIVE"
MISSING_MODALITY = "MISSING_MODALITY"
MISSING_OPENED_AT = "MISSING_OPENED_AT"
MISSING_COMPLAINT_PROTOCOL = "MISSING_COMPLAINT_PROTOCOL"
MISSING_TICKET_ID = "MISSING_TICKET_ID"
FIELD_ERROR = "FIELD_ERROR"
INVALID_VALUE = "INVALID_VALUE"
MISSING_ADDRESS_CEP = "MISSING_ADDRESS_CEP"
MISSING_ADDRESS_STREET = "MISSING_ADDRESS_STREET"
MISSING_ADDRESS_NEIGHBORHOOD = "MISSING_ADDRESS_NEIGHBORHOOD"
MISSING_ADDRESS_CITY = "MISSING_ADDRESS_CITY"
MISSING_ADDRESS_STATE = "MISSING_ADDRESS_STATE"
MISSING_DESCRIPTION = "MISSING_DESCRIPTION"
MISSING_SUBSCRIBER_CPF = "MISSING_SUBSCRIBER_CPF"
MISSING_SUBSCRIBER_NAME = "MISSING_SUBSCRIBER_NAME"
class InputChannel(str, Enum):
ANATEL = "anatel"
class CaseType(str, Enum):
ANATEL = "anatel"
# Global mapping for Pydantic location tuples to (ReasonCode, message)
ERROR_CODE_MAPPING = {
# Customer Identity
("customer", "cpfCnpj"): (ReasonCode.MISSING_CPF_CNPJ, "Customer CPF/CNPJ is required"),
("customer", "name"): (ReasonCode.FIELD_ERROR, "Customer name is required"),
("customer", "odcCustomer"): (ReasonCode.FIELD_ERROR, "Customer odcCustomer indicator is required"),
("customer", "contumazCustomer"): (ReasonCode.FIELD_ERROR, "Customer contumazCustomer indicator is required"),
# Address
("customer", "address", "cep"): (ReasonCode.MISSING_ADDRESS_CEP, "Address CEP is required"),
("customer", "address", "street"): (ReasonCode.MISSING_ADDRESS_STREET, "Address street is required"),
("customer", "address", "neighborhood"): (ReasonCode.MISSING_ADDRESS_NEIGHBORHOOD, "Address neighborhood is required"),
("customer", "address", "city"): (ReasonCode.MISSING_ADDRESS_CITY, "Address city is required"),
("customer", "address", "state"): (ReasonCode.MISSING_ADDRESS_STATE, "Address state (UF) is required"),
# Subscriber
("customer", "subscriber", "cpfCnpj"): (ReasonCode.MISSING_SUBSCRIBER_CPF, "Subscriber CPF/CNPJ is required"),
("customer", "subscriber", "subscriberName"): (ReasonCode.MISSING_SUBSCRIBER_NAME, "Subscriber name is required"),
# Complaint
("complaint", "complaintProtocol"): (ReasonCode.MISSING_COMPLAINT_PROTOCOL, "Complaint protocol is required"),
("complaint", "motive"): (ReasonCode.MISSING_MOTIVE, "Complaint motive is required"),
("complaint", "modality"): (ReasonCode.MISSING_MODALITY, "Complaint modality is required"),
("complaint", "openedAt"): (ReasonCode.MISSING_OPENED_AT, "Complaint openedAt date is required"),
("complaint", "description"): (ReasonCode.MISSING_DESCRIPTION, "Complaint description is required"),
("complaint", "actionType"): (ReasonCode.FIELD_ERROR, "Complaint actionType is required"),
("complaint", "inputChannel"): (ReasonCode.FIELD_ERROR, "Complaint inputChannel is required"),
("complaint", "service"): (ReasonCode.FIELD_ERROR, "Complaint service is required"),
("complaint", "firstService"): (ReasonCode.FIELD_ERROR, "Complaint firstService is required"),
# Origin & Meta
("origin", "sourceSystem"): (ReasonCode.FIELD_ERROR, "Origin sourceSystem is required"),
("origin", "submittedBy", "userId"): (ReasonCode.FIELD_ERROR, "Origin userId is required"),
("origin", "submittedBy", "name"): (ReasonCode.FIELD_ERROR, "Origin submittedBy name is required"),
("caseType",): (ReasonCode.FIELD_ERROR, "CaseType is required"),
}
class ValidationErrorDetailMessage(BaseModel):
code: str
text: str
class ValidationErrorDetail(BaseModel):
messages: List[ValidationErrorDetailMessage]
class ValidationResult(BaseModel):
is_valid: bool
error_response: Optional[Dict[str, Any]] = None
validated_fields: List[str] = Field(default_factory=list)
missing_fields: List[str] = Field(default_factory=list)
# --- Swagger v0.0.5 aligned schemas ---
class SubmittedBy(BaseModel):
userId: str
name: str
email: Optional[str] = None
class Origin(BaseModel):
sourceSystem: str
submittedBy: SubmittedBy
class Address(BaseModel):
cep: str
street: str
neighborhood: str
city: str
state: str
class Subscriber(BaseModel):
cpfCnpj: str
subscriberName: str
contactPhone: Optional[str] = None
contactName: Optional[str] = None
class Customer(BaseModel):
cpfCnpj: str
phones: List[str]
msisdn: Optional[str] = None
name: str
email: Optional[str] = None
govBrSeal: Optional[bool] = None
hasAttachment: Optional[bool] = None
odcCustomer: bool
contumazCustomer: bool
address: Address
subscriber: Subscriber
class Complaint(BaseModel):
complaintProtocol: str
actionType: str # nova | reabertura
providerProtocol: Optional[str] = None
inputChannel: InputChannel
service: str
firstService: str
modality: str
motive: str
description: str
openedAt: datetime
dueAt: Optional[datetime] = None # readOnly
@field_validator("inputChannel", mode="before")
@classmethod
def normalize_input_channel(cls, v):
if isinstance(v, str):
return v.lower()
return v
class ComplaintHistoryItem(BaseModel):
"""Reclamação anterior do cliente, usada para enriquecer a análise atual."""
complaintProtocol: str
status: str
actionType: str # nova | reabertura
providerProtocol: Optional[str] = None
openedAt: datetime
inputChannel: str
service: str
firstService: str
modality: str
motive: str
description: str
cpfCnpj: str
msisdn: Optional[str] = None
phones: Optional[List[str]] = None
class TicketRequestEvent(BaseModel):
"""Schema for events consumed from Kafka (CMS → Agent direction)."""
caseType: CaseType
origin: Origin
crmProtocol: Optional[str] = None
ticketId: Optional[str] = None
customer: Customer
complaint: Complaint
complaintHistory: Optional[List[ComplaintHistoryItem]] = None
transactionId: Optional[str] = Field(default=None, alias="transactionId")
model_config = {"populate_by_name": True}
@field_validator("caseType", mode="before")
@classmethod
def normalize_case_type(cls, v):
if isinstance(v, str):
return v.lower()
return v
class KeyType(str, Enum):
STRING = "string"
NUMBER = "number"
BOOLEAN = "boolean"
class FieldsToUpdate(BaseModel):
keyType: KeyType
keyDesc: str
keyName: str
class ForwardingDecision(BaseModel):
decision: str
complaint_description_analysis: str | None = None
target_operator: str
forwarding_reason: str
class TreatmentDecisionDetails(BaseModel):
agent_type: str
target_agent: str
class TreatmentDecision(BaseModel):
category: str
decision: TreatmentDecisionDetails
treatment_decision_reasoning: str
# --- Processing & Response schemas (Agent → CMS direction) ---
class ProcessingStatus(str, Enum):
PROCESSING = "processing"
OPENED_RECLASSIFICATION_SR = "opened_reclassification_sr"
OPENED_TREATMENT_SR = "opened_treatment_sr"
OPENED_CANCELATION_SR = "opened_cancelation_sr"
OPENED_FORWARDING_SR = "opened_forwarding_sr"
RESPONSE = "response"
DONE = "done"
FAILED = "failed"
# Emulator lifecycle states.
# `processing` (re-used from the checklist enum) marks the first-response
# generation in flight; `processing_regeneration` distinguishes a
# subsequent regenerate run from the previous `awaiting_review` so the
# frontend polling can detect the transition without inspecting
# `validation.feedback_registered`.
PROCESSING_REGENERATION = "processing_regeneration"
AWAITING_REVIEW = "awaiting_review"
APPROVED = "approved"
SIEBEL_CLOSING_FAILED = "siebel_closing_failed"
class ProcessingAction(str, Enum):
ATG = "atg"
RESPONSE = "response"
AWAIT_RESPONSE = "await_response"
RETRY = "retry"
UPDATE = "update"
class Processing(BaseModel):
"""
Agent processing results, including LLM reasoning and final status of the graph.
"""
status: ProcessingStatus
current_step: str # current graph state when processing/done/failed status occurs
action: ProcessingAction
note: Optional[str] = Field(default=None, alias="note")
crmProtocol: Optional[str] = Field(default=None, alias="crmProtocol")
fieldsToUpdate: Optional[List[FieldsToUpdate]] = Field(default=None, alias="fieldsToUpdate")
case_response: Optional[str] = None
# Emulator lifecycle history; left None by the checklist flow.
transitions: Optional[List[Dict[str, Any]]] = None
metadata: Optional[Dict[str, Any]] = Field(default_factory=dict)
model_config = {"populate_by_name": True}
class TicketResponseEvent(BaseModel):
"""Schema for events published back to CMS after agent processing."""
transactionId: str = Field(..., alias="transactionId")
processing: Processing
# Top-level case-doc metadata fields the CMS should $set with dot
# notation. Used by the emulator to persist `metadata.validation` and
# `metadata.selected_actions` outside `processing`. None for checklist.
metadata: Optional[Dict[str, Any]] = None
model_config = {"populate_by_name": True}
# --- Progress event schemas (incremental sub-status during `processing`) ---
class ProgressProcessing(BaseModel):
"""
Sub-status frame published while the main status is `processing`.
The CMS callback (`on_agent_response`) only requires `processing.status`;
the remaining fields are stored as-is via `$set`.
"""
status: ProcessingStatus = ProcessingStatus.PROCESSING
current_step: str
action: ProcessingAction = ProcessingAction.AWAIT_RESPONSE
note: Optional[str] = None
timestamp: str
class ProgressEvent(BaseModel):
"""Incremental progress event emitted on every persistable GraphStep transition."""
transactionId: str
processing: ProgressProcessing
# --- Stream envelope (CMS → Agent, discriminado por event_type) ---
EventType = Literal["checklist", "response_emulator"]
class StreamEnvelope(BaseModel):
"""Envelope discriminado das mensagens publicadas pelo CMS na fila OCI
Streaming. Transporta os dois fluxos:
- checklist → TicketRequestEvent
- response_emulator → ResponseEmulatorRequestEvent
A rota REST do emulador (POST /case/{tx}/response-emulator/{generate,
finalize}) continua funcionando para chamadas síncronas; o streaming é
o caminho assíncrono disparado pelo CMS.
Mensagens "cruas" (sem envelope, formato legado do checklist) ainda são
suportadas pelo consumer via fallback retrocompat — ver
`src/infrastructure/streaming/consumer.py`.
"""
event_type: EventType
data: Union[TicketRequestEvent, ResponseEmulatorRequestEvent]

View File

@@ -0,0 +1,26 @@
"""Pydantic schemas for the emulator RAG test endpoint."""
from enum import Enum
from pydantic import BaseModel
class EmulatorRagSourceEnum(str, Enum):
templates = "templates"
anatel_resposta = "anatel_resposta"
class EmulatorRagSearchResultItem(BaseModel):
id: str
content: str
distance: float
metadata: dict | None = None
class EmulatorRagSearchResponse(BaseModel):
results: list[EmulatorRagSearchResultItem]
total_results: int
source: EmulatorRagSourceEnum
query: str
top_k: int
sql: str

View File

@@ -0,0 +1,15 @@
from pydantic import BaseModel, Field
from typing import Dict, Any, Optional
class ImdbRequest(BaseModel):
msisdn: Optional[str] = Field(None, description="Customer MSISDN")
client_id: str = Field(..., description="Client identification")
class ImdbResponse(BaseModel):
plan: Optional[Dict[str, Any]] = Field(None, description="Customer's plan")
statusType: Optional[str] = Field(None, description="Status Type")
statusDescription: Optional[str] = Field(None, description="Status description")
socialSecNo: Optional[str] = Field(
None,
description="CPF/CNPJ do titular da linha conforme retornado pela Base TIM (IMDB)"
)

View File

@@ -0,0 +1,30 @@
from pydantic import BaseModel, Field
from typing import Optional
class PortabilityRequest(BaseModel):
msisdn: str = Field(..., description="Customer's MSISDN")
socialSecNo: str = Field(..., description="Customer's social security number (CPF)")
class PortabilityItem(BaseModel):
socialSecNo: Optional[str] = Field(None, description="CPF associado ao registro de portabilidade")
msisdn: Optional[str] = Field(None, description="MSISDN do registro de portabilidade")
activationWindow: Optional[str] = Field(None, description="Janela de ativação da portabilidade (ISO 8601)")
operatorsName: Optional[str] = Field(None, description="Operadora de destino (recipiente) da portabilidade")
operatorsEot: Optional[str] = Field(None, description="Código EOT da operadora de destino")
operatorsCode: Optional[str] = Field(None, description="Código da operadora de destino")
operatorsDonorName: Optional[str] = Field(None, description="Operadora de origem (doadora) da portabilidade")
protocol: Optional[str] = Field(None, description="Protocolo da portabilidade")
status: Optional[str] = Field(None, description="Status do registro de portabilidade")
requestedDate: Optional[str] = Field(None, description="Data de solicitação da portabilidade (ISO 8601)")
cancellationDate: Optional[str] = Field(None, description="Data de efetivação/cancelamento da portabilidade (ISO 8601)")
cancellationDesc: Optional[str] = Field(None, description="Descrição do motivo de cancelamento")
class PortabilityResponse(BaseModel):
msisdn: Optional[str] = Field(None, description="Customer's MSISDN")
socialSecNo: Optional[str] = Field(None, description="Customer's social security number (CPF)")
statusType: Optional[str] = Field(None, description="Status type returned by Portability API")
statusDescription: Optional[str] = Field(None, description="Status description returned by Portability API")
portabilities: list[PortabilityItem] = Field(default_factory=list, description="Histórico de portabilidades do número")

View File

@@ -0,0 +1,53 @@
"""
Request schemas for the agent API.
This module defines Pydantic models for validating incoming requests.
"""
from pydantic import BaseModel, Field, field_validator
from typing import Optional, Dict, Any
class AgentRequest(BaseModel):
"""
Request model for agent execution.
Attributes:
message: User message to process (required, 1-10000 characters)
session_id: Optional session ID for conversation continuity
context: Optional additional context for the agent
"""
message: str = Field(
...,
min_length=1,
max_length=10000,
description="User message to process"
)
session_id: Optional[str] = Field(
None,
description="Session ID for conversation continuity"
)
context: Optional[Dict[str, Any]] = Field(
default_factory=dict,
description="Additional context for the agent"
)
@field_validator('message')
@classmethod
def message_not_empty(cls, v: str) -> str:
"""Validate that message is not empty or whitespace only."""
if not v.strip():
raise ValueError('Message cannot be empty or whitespace only')
return v.strip()
model_config = {
"json_schema_extra": {
"examples": [
{
"message": "Hello, how can you help me?",
"session_id": "user-123-session-456",
"context": {"user_id": "123", "language": "en"}
}
]
}
}

View File

@@ -0,0 +1,77 @@
"""
Response schemas for the agent API.
This module defines Pydantic models for API responses.
"""
from pydantic import BaseModel, Field
from typing import Dict, Any
from datetime import datetime
class AgentResponse(BaseModel):
"""
Response model from agent execution.
Attributes:
response: Agent's response message
session_id: Session ID for conversation tracking
metadata: Execution metadata (tokens, time, etc.)
timestamp: Response timestamp in UTC
"""
response: str = Field(..., description="Agent's response")
session_id: str = Field(..., description="Session ID")
metadata: Dict[str, Any] = Field(
default_factory=dict,
description="Execution metadata (tokens, time, etc.)"
)
timestamp: datetime = Field(
default_factory=datetime.utcnow,
description="Response timestamp"
)
model_config = {
"json_schema_extra": {
"examples": [
{
"response": "I can help you with various tasks...",
"session_id": "user-123-session-456",
"metadata": {
"execution_time_ms": 1234,
"tokens_used": 150,
"model": "gpt-4"
},
"timestamp": "2024-01-15T10:30:00Z"
}
]
}
}
class HealthResponse(BaseModel):
"""
Health check response.
Attributes:
status: Service status (healthy, unhealthy)
version: Service version
timestamp: Check timestamp in UTC
"""
status: str = Field(..., description="Service status")
version: str = Field(..., description="Service version")
timestamp: datetime = Field(
default_factory=datetime.utcnow,
description="Check timestamp"
)
model_config = {
"json_schema_extra": {
"examples": [
{
"status": "healthy",
"version": "1.0.0",
"timestamp": "2024-01-15T10:30:00Z"
}
]
}
}

View File

@@ -0,0 +1,230 @@
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any, Literal
from src.utils.text import truncate_text
class SiebelSRRequest(BaseModel):
"""
DTO representing the full payload for a Siebel Service Request.
Dynamic fields (required): social_sec_no, asset_id, reason1, reason2, reason3.
Static/mocked fields: have defaults and do not need to be provided by callers as of Sprint 1.
"""
# Top-level fields
channel: str = Field(default="Anatel", description="Channel")
social_sec_no: str = Field(..., description="Customer CPF or CNPJ (socialSecNo)")
asset_id: str = Field(..., description="Customer MSISDN (assetId)")
# serviceRequest fields
sr_type: str = Field(default="0119", description="Service Request type")
user_id: str = Field(default="SADMIN", description="User ID")
reason1: str = Field(..., description="Level 1 reason")
reason2: str = Field(..., description="Level 2 reason")
reason3: str = Field(..., description="Level 3 reason")
status: str = Field(default="Encaminhado", description="Service Request status")
notes: str = Field(default="Notas", description="Additional notes")
# interaction fields
interaction_source: str = Field(default="Cliente", description="Interaction source")
interaction_request_flag: bool = Field(default=False, description="Interaction request flag")
interaction_direction_contact: str = Field(default="FROM-CLIENT", description="Direction of contact")
interaction_status: str = Field(default="OPENED", description="Interaction status")
@staticmethod
def build_notes(
type: Literal["tratamento", "reclassificar", "cancelar", "reencaminhar"],
data: str,
protocolo_anatel: str,
acao: str,
cpf_cliente: str,
nome_cliente: str,
telefone_reclamado: str,
telefone_contato: str,
endereco: str,
cep: str,
cidade: str,
estado: str,
servico: str,
primeiro_servico: str,
modalidade: str,
motivo: str,
descricao_reclamacao: str,
motivo_extra: Optional[str] = None,
) -> str:
"""
Builds the notes field following the template.
"""
header_map = {
"tratamento": "Atendimento criado para a seguinte reclamação Anatel:",
"reclassificar": "Atendimento criado para registrar a solicitação de reclassificação na Anatel:",
"cancelar": "Atendimento criado para registrar a solicitação de cancelamento na Anatel:",
"reencaminhar": "Atendimento criado para registrar a solicitação de reencaminhamento na Anatel:",
}
notes = f"""{header_map[type]} |
Data: {data} |
Protocolo Anatel: {protocolo_anatel} |
Ação: {acao} |
CPF Cliente: {cpf_cliente} |
Nome Cliente: {nome_cliente} |
Telefone Reclamado: {telefone_reclamado} |
Telefone Contato: {telefone_contato} |
Endereço: {endereco} |
CEP: {cep} |
Cidade: {cidade} |
Estado: {estado} |
Serviço: {servico} |
Primeiro Serviço: {primeiro_servico} |
Modalidade: {modalidade} |
Motivo: {motivo} |
Descrição Reclamação: {descricao_reclamacao} |
""".replace("\n", "")
label_map = {
"cancelar": "Motivo do Cancelamento",
"reclassificar": "Motivo da Reclassificação",
"reencaminhar": "Motivo do Reencaminhamento",
}
if type in label_map and motivo_extra:
notes += f"{label_map[type]}: {motivo_extra}"
# Truncate to Siebel limit (1400 chars)
return truncate_text(notes.strip(), 1400)
def to_payload(self) -> Dict[str, Any]:
"""Serializes the DTO into the dict format expected by SiebelClient."""
return {
"channel": self.channel,
"socialSecNo": self.social_sec_no,
"assetId": self.asset_id,
"serviceRequest": {
"type": self.sr_type,
"userId": self.user_id,
"reason1": self.reason1,
"reason2": self.reason2,
"reason3": self.reason3,
"status": self.status,
"notes": self.notes,
},
"interaction": {
"source": self.interaction_source,
"requestFlag": self.interaction_request_flag,
"directionContact": self.interaction_direction_contact,
"status": self.interaction_status,
},
}
class SiebelSRStatusRequestPosPago(BaseModel):
"""
DTO for the Siebel statusServiceRequest payload (SR closing — pós-pago).
"""
channel: str = Field(default="Anatel")
protocol_number: str = Field(..., description="Número da SR no Siebel (interactionProtocol)")
status: str = Field(default="Fechado")
notes: str = Field(...)
date: str = Field(..., description="Data no formato MM/DD/YYYY HH:MM:SS")
@staticmethod
def build_notes(complaint_protocol: str, accepted_response: str) -> str:
return (
f"Atendimento finalizado para a reclamação Anatel {complaint_protocol}"
f" com a seguinte resposta: {accepted_response}"
)
def to_payload(self) -> Dict[str, Any]:
return {
"channel": self.channel,
"serviceRequest": {
"status": self.status,
"notes": self.notes,
"protocolNumber": self.protocol_number,
},
"date": self.date,
}
class SiebelSRStatusRequestPrePago(BaseModel):
"""
DTO for the Siebel pré-pago SR closing payload (PATCH /customers/v1/serviceRequest).
"""
protocol: str = Field(..., description="Número da SR no Siebel (interactionProtocol)")
msisdn: str = Field(..., description="MSISDN do cliente")
status: str = Field(default="Closed")
close_date: str = Field(..., description="Data no formato DD-MM-YYYY HH:MM:SS")
notes: str = Field(...)
def to_payload(self) -> Dict[str, Any]:
return {
"protocol": self.protocol,
"msisdn": self.msisdn,
"status": self.status,
"closeDate": self.close_date,
"notes": self.notes,
}
class SiebelSRResponse(BaseModel):
success: bool
message: str
service_request_number: Optional[str] = None
data: Optional[Dict[str, Any]] = None
class SiebelProspectSRRequest(BaseModel):
"""
DTO representing the payload for the Siebel Prospect SR API
(used when IMDB indicates non-TIM number or canceled customer).
"""
# Customer dynamic fields
name: str = Field(..., description="Customer full name")
document_number: str = Field(..., description="Customer CPF/CNPJ (interaction.documentNumber)")
email: Optional[str] = Field(default=None, description="Customer email")
phone1: Optional[str] = Field(default=None, description="Customer contact phone")
# SR dynamic fields
reason1: str = Field(..., description="Level 1 reason")
reason2: str = Field(..., description="Level 2 reason")
reason3: str = Field(..., description="Level 3 reason")
notes: str = Field(..., description="SR notes")
# Static / configurable fields
login: str = Field(default="SADMIN", description="Login")
customer_type: str = Field(default="PF", description="Customer type (PF/PJ) - Pessoa Física ou Jurídica")
user_doc_num_pdv: str = Field(default="", description="PDV number document")
user_name_pdv: str = Field(default="", description="PDV user name — to be confirmed")
cust_code_pdv: str = Field(default="", description="PDV customer code — to be confirmed")
channel: str = Field(default="Anatel", description="Channel")
sr_status: str = Field(default="Encaminhado", description="Service Request status, for new opening requests use 'Encaminhado'")
status: str = Field(default="OPENED", description="Interaction status, for new opening requests use 'OPENED'")
def to_payload(self) -> Dict[str, Any]:
"""Serializes the DTO into the dict format expected by the Prospect API."""
return {
"login": self.login,
"customer": {
"msisdn": "",
"type": self.customer_type,
"name": self.name,
"email": {"email": self.email or ""},
"contact": {"phone1": self.phone1 or ""},
},
"interaction": {
"userDocNumPdv": self.user_doc_num_pdv,
"userNamePdv": self.user_name_pdv,
"documentNumber": self.document_number,
"reason1": self.reason1,
"reason2": self.reason2,
"reason3": self.reason3,
"channel": self.channel,
"notes": self.notes,
"srStatus": self.sr_status,
"custCodePdv": self.cust_code_pdv,
"status": self.status,
},
}

View File

@@ -0,0 +1,59 @@
"""
Response schemas for the TAIS Knowledge Base API.
This module defines Pydantic models for TAIS KB search responses.
"""
from pydantic import BaseModel, Field
from typing import List
class TaisKbSearchResultItem(BaseModel):
"""
Single document result from TAIS KB search.
Attributes:
id_proc: Document ID
title_proc: Document title/procedure name
description_proc: Document description
content: Document content
segment: Document segment classification
sub_segments: Document sub-segment classification
distance: Cosine distance (0-1, lower is better)
"""
id_proc: str = Field(..., description="Document ID")
title_proc: str = Field(..., description="Document title/procedure name")
description_proc: str = Field(..., description="Document description")
content: str = Field(..., description="Document content")
segment: str = Field(..., description="Document segment classification")
sub_segments: str = Field(..., description="Document sub-segment classification")
distance: float = Field(..., description="Cosine distance (0-1, lower is more similar)")
class TaisKbSearchResponse(BaseModel):
"""
Response model from TAIS KB search.
Attributes:
results: List of search results
total_results: Number of results returned
query: Original query text
reformulated_query: Query after preprocessing with OCI GenAI (if preprocess=true)
postprocessing_content: Synthesized answer from LLM postprocessing (if postprocess=true)
postprocessing_id_procs: List of document IDs used in postprocessing (if postprocess=true)
postprocessing_id_procs_map: Mapping of id_proc to title_proc for postprocessing results (if postprocess=true)
postprocessing_prompt: Complete filled prompt sent to LLM for postprocessing (if postprocess=true)
sql: SQL query that was executed
"""
results: List[TaisKbSearchResultItem] = Field(
...,
description="Search results sorted by relevance (lowest distance first)"
)
total_results: int = Field(..., description="Number of results returned")
query: str = Field(..., description="Original query text")
reformulated_query: str | None = Field(None, description="Query after preprocessing with OCI GenAI (if preprocess=true)")
postprocessing_content: str | None = Field(None, description="Synthesized answer from LLM postprocessing (if postprocess=true)")
postprocessing_id_procs: list[str] | None = Field(None, description="Document IDs used in postprocessing response (if postprocess=true)")
postprocessing_id_procs_map: dict[str, str] | None = Field(None, description="Mapping of id_proc to title_proc for postprocessing results (if postprocess=true)")
postprocessing_prompt: str | None = Field(None, description="Complete filled prompt sent to LLM for postprocessing (if postprocess=true)")
sql: str = Field(..., description="SQL query that was executed")

View File

@@ -0,0 +1,230 @@
import json
import logging
from src.agent.state.agent_state import AgentState
from src.agent.state.steps import GraphStep
from typing import Any, Optional, Tuple
from enum import Enum
from fastapi import status
from fastapi.responses import JSONResponse
from src.api.schemas.anatel_schemas import TicketResponseEvent, Processing, FieldsToUpdate, KeyType
from src.agent.state.agent_state import get_last_ai_message
from src.utils.external_response_builder import build_external_response
logger = logging.getLogger(__name__)
def create_error_response(
status_code: int,
correlation_id: str,
final_response: str,
parsed_response: dict = None,
metadata: dict = None,
) -> JSONResponse:
"""Standardized error envelope used by agent routes."""
if isinstance(parsed_response, dict):
error_payload = {
"title": parsed_response.get("title", "execution error"),
"status": parsed_response.get("status", status_code),
"correlation_id": correlation_id,
"detail": parsed_response.get("detail", {"messages": []}),
}
else:
error_payload = {
"title": "system error" if status_code >= 500 else "validation error",
"status": status_code,
"correlation_id": correlation_id,
"detail": {
"messages": [{"code": "EXECUTION_ERROR", "text": str(final_response)}]
},
}
if metadata:
error_payload["metadata"] = metadata
return JSONResponse(status_code=status_code, content=error_payload)
_SR_STATUSES = [
"opened_reclassification_sr",
"opened_treatment_sr",
"opened_cancelation_sr",
"opened_forwarding_sr"
]
def build_ticket_tags(event_context: dict, complaint_context: dict) -> list[str]:
"""Builds Langfuse tags from ticket event fields, used before graph execution."""
case_type = event_context.get("caseType", "unknown")
service = complaint_context.get("service", "unknown")
modality = complaint_context.get("modality", "unknown")
action_type = complaint_context.get("actionType", "unknown")
gov_br_seal = event_context.get("customer", {}).get("govBrSeal") or False
return [
f"caseType: {case_type.value.capitalize() if isinstance(case_type, Enum) else case_type}",
f"service: {service}",
f"actionType: {action_type.capitalize()}",
f"govBrSeal: {str(gov_br_seal).lower()}",
]
def resolve_outcome_tag(current_step: str, error_info: Optional[dict]) -> str:
"""Returns the outcome tag based on graph execution result, used after graph execution."""
if current_step == GraphStep.VALIDATION_FAILED:
return "outcome:validation_failed"
if error_info:
return "outcome:failed"
return "outcome:completed"
def extract_response_payload(state: AgentState) -> Tuple[str, Any]:
"""
Standardizes the extraction of the final response from agent state.
Returns a tuple of (raw_string, parsed_json_or_string).
"""
final_response = state.get("final_response")
if not final_response:
last_ai_msg = get_last_ai_message(state)
final_response = last_ai_msg.content if last_ai_msg else "No response generated"
parsed = None
try:
parsed = json.loads(final_response)
except Exception:
parsed = final_response
return final_response, parsed
def get_http_status_code(state: AgentState, parsed_response: Any) -> int:
"""
Determines the appropriate HTTP status code based on state and response content.
"""
current_step = state.get("current_step", "unknown")
error_state = state.get("error")
is_error = bool(error_state) or current_step == GraphStep.VALIDATION_FAILED
status_code = status.HTTP_200_OK
# Try to extract status code from parsed response if it's a dict
if isinstance(parsed_response, dict) and "status" in parsed_response:
try:
resp_status = int(parsed_response.get("status"))
if resp_status >= 400:
is_error = True
status_code = resp_status
except (ValueError, TypeError):
pass
if is_error and status_code == status.HTTP_200_OK:
is_val_err = (current_step == GraphStep.VALIDATION_FAILED or
(error_state and error_state.get("type") == "ValidationError"))
status_code = status.HTTP_400_BAD_REQUEST if is_val_err else status.HTTP_500_INTERNAL_SERVER_ERROR
return status_code
def build_cms_response_event(state: AgentState, correlation_id: str) -> TicketResponseEvent:
"""
Unified logic to build the TicketResponseEvent for both API and Background Worker.
"""
context = state.get("metadata", {}).get("request_context", {})
sr_data = context.get("siebel_sr_data", {})
sr_number = sr_data.get("interactionProtocol", "N/A")
decision = context.get("siebel_action", "done")
current_step = state.get("current_step", "unknown")
status_mapping = {
"reclassificar": "opened_reclassification_sr",
"tratamento": "opened_treatment_sr",
"cancelar": "opened_cancelation_sr",
"reencaminhar": "opened_forwarding_sr"
}
if current_step == GraphStep.VALIDATION_FAILED or sr_number == "N/A":
cms_status = "failed"
else:
cms_status = status_mapping.get(decision.lower() if isinstance(decision, str) else "done", "done")
# Determine response action
if cms_status in _SR_STATUSES:
action = "update"
elif sr_number != "N/A":
action = "response"
else:
action = "atg"
# Build fieldsToUpdate for reclassification
fields_to_update = None
if cms_status == "opened_reclassification_sr":
fields_to_update = [
FieldsToUpdate(keyType=KeyType.STRING, keyDesc="Motivo/Problema da reclamação a ser corrigido", keyName="motive"),
FieldsToUpdate(keyType=KeyType.STRING, keyDesc="Modalidade/Assunto da reclamação a ser corrigida", keyName="modality")
]
# Build enhanced metadata
metadata = {
"siebel_action": decision,
}
for key in ("canceling_decision", "forwarding_decision", "reclassification_decision", "treatment_decision"):
if context.get(key):
metadata[key] = context.get(key)
# Add knowledge base enrichment data if available
relevant_docs = context.get("relevant_documents")
if relevant_docs:
kb_payload = {
"query": relevant_docs.get("query"),
"documents": relevant_docs.get("documents") or [],
}
if relevant_docs.get("message"):
kb_payload["message"] = relevant_docs["message"]
if relevant_docs.get("postprocessing_id_procs_map"):
kb_payload["postprocessing_id_procs_map"] = relevant_docs["postprocessing_id_procs_map"]
metadata["relevant_documents"] = kb_payload
# Add speech enrichment data if available
speech = context.get("speech_analytics")
if speech:
atual_keys = (
"reclamacao_resumo",
"causa_raiz",
"descortesia_cliente",
"motivo_reclamacao",
"submotivo_reclamacao",
"sentimento_cliente",
"solucao_proposta_cliente",
)
related_keys = ("protocolo", "data_reclamacao", "similaridade_pct") + atual_keys
historico_relacionado = [
{k: item.get(k) for k in related_keys}
for item in (speech.get("historico_relacionado") or [])
]
metadata["speech_retrieved_data"] = {
"reclamacao_atual": {k: speech.get(k) for k in atual_keys},
"historico": {
"relacionado": historico_relacionado or [],
"analise_agente": speech.get("analise_agente"),
},
}
# Include execution markers
metadata.update({
"iteration_count": state.get("iteration_count", 0),
"error": state.get("error")
})
case_response = build_external_response(context, decision.lower() if isinstance(decision, str) else "")
return TicketResponseEvent(
transactionId=correlation_id,
processing=Processing(
status=cms_status,
current_step=current_step,
action=action,
note=state.get("processing_notes") or None,
crmProtocol=sr_number if sr_number != "N/A" else None,
fieldsToUpdate=fields_to_update,
case_response=case_response,
metadata=metadata
)
)

View File

@@ -0,0 +1,202 @@
"""Builds the `TicketResponseEvent` for the Response Emulator flow.
Kept separate from `agent_helpers.build_cms_response_event` (checklist's
builder) to avoid coupling the two domains. Status mapping (status,action):
error at any step → failed, retry
flow_mode="generate" → awaiting_review, await_response
flow_mode="approve" → approved, await_response
flow_mode="close" → done, response
The event is meant to be the LAST message published on the OCI Response
Stream for a given case, so the CMS callback resolves the final DB state
without races against earlier ProgressEvents.
## Where each field lives (split by lifetime)
- `processing.metadata` (per-run scratch, lives only in the latest
`processing` subdoc): just `error` when the graph failed. Everything else
used to be here too and was duplicating the case-doc `metadata` below —
the GET endpoint already reads validation/selected_actions from the
case-level `metadata`, so keeping a parallel copy under `processing` was
pure dead weight that the CMS overwrote on every run.
- `metadata` (top-level case doc, persisted incrementally via `$set` with
dot-notation): selected_actions, validation, flow_mode, emulator_routing,
and the `last_emulation` summary. These survive across runs and merge
cleanly with whatever the checklist already wrote to `metadata`.
"""
from datetime import datetime, timezone
from src.agent.state.agent_state import AgentState
from src.agent.state.steps_emulator import EmulatorGraphStep
from src.api.schemas.anatel_schemas import (
Processing,
ProcessingAction,
ProcessingStatus,
TicketResponseEvent,
)
# Maps flow_mode → (status, action) when the graph completes without error.
_FLOW_MODE_OUTCOMES: dict[str, tuple[ProcessingStatus, ProcessingAction]] = {
"generate": (ProcessingStatus.AWAITING_REVIEW, ProcessingAction.AWAIT_RESPONSE),
"approve": (ProcessingStatus.APPROVED, ProcessingAction.AWAIT_RESPONSE),
"close": (ProcessingStatus.DONE, ProcessingAction.RESPONSE),
}
def _resolve_crm_protocol(case_data: dict | None) -> str | None:
"""Returns the persisted Siebel SR protocol, or None when no SR exists.
Why this is restricted to two sources (and NOT `case_data.crmProtocol`
at the root or `request_context.crmProtocol`): the root-level field on
the request event is whatever the simulator/CMS sent in — for our test
payloads it's `"DS-XXXXXXXX"`, a ticketId-shaped placeholder, not the
real Anatel-style SR protocol (`"20260..."`) that Siebel returns when
the treatment SR opens. Falling back to it would propagate that bogus
value into `processing.crmProtocol` and the close PATCH would close
the wrong SR.
Trustworthy sources, in order:
1. `processing.crmProtocol` — written by the checklist's terminal
event from `siebel_sr_data.interactionProtocol`. Canonical.
2. `siebel_sr_data.interactionProtocol` — in-memory only; relevant
when the checklist + emulator run in the same process (tests).
The emulator MUST echo this back on every terminal event because the
CMS callback overwrites the `processing` subdoc; omitting the field
zeroes it, which is the bug that broke `close_case` before.
"""
case_data = case_data or {}
processing = case_data.get("processing") or {}
sr_data = case_data.get("siebel_sr_data") or {}
return processing.get("crmProtocol") or sr_data.get("interactionProtocol")
def _resolve_persisted_field(
case_data: dict | None,
new_value,
field: str,
):
"""Returns `new_value` when truthy, else the persisted value in `case_data.processing.{field}`.
Why: when a graph run fails (e.g. `approve_draft` errors because of a
transient issue), the state's `metadata.{field}` is empty for that
run. If we publish `Processing(field=None)`, the CMS callback wipes
the value the previous successful run had persisted — turning a
recoverable failure into permanent data loss (this is exactly what
nuked `case_response`/`transitions` mid-flow before this fix). When
the new run actually produced a value, that wins; otherwise we echo
what is already in the doc so the failure is non-destructive.
"""
if new_value:
return new_value
processing = (case_data or {}).get("processing") or {}
return processing.get(field)
def _resolve_outcome(
error_info: dict | None,
case_response: str | None,
flow_mode: str | None,
) -> tuple[ProcessingStatus, ProcessingAction]:
if error_info and flow_mode == "close":
return ProcessingStatus.SIEBEL_CLOSING_FAILED, ProcessingAction.RETRY
if error_info:
return ProcessingStatus.FAILED, ProcessingAction.RETRY
if flow_mode == "close" and not case_response:
return ProcessingStatus.FAILED, ProcessingAction.RETRY
if flow_mode == "generate" and not case_response:
return ProcessingStatus.FAILED, ProcessingAction.RETRY
return _FLOW_MODE_OUTCOMES.get(
flow_mode or "",
(ProcessingStatus.FAILED, ProcessingAction.RETRY),
)
def _build_case_metadata(
selected_actions: list,
validation: dict,
flow_mode: str | None,
routing: dict | None,
emulation_type: str | None,
iteration_count: int,
) -> dict:
"""Top-level case-doc metadata for the CMS to `$set` via dot notation.
Empty/None values are omitted so a partial run (e.g. `approve` has no
new `selected_actions` or `validation`) doesn't blow away keys written
by an earlier run — this is the incremental-merge contract with the
CMS callback. `last_emulation` is always included as a fresh marker
of when the most recent graph run happened and what it was.
"""
case_metadata: dict = {
"last_emulation": {
"type": emulation_type,
"is_regeneration": emulation_type == "regenerate",
"flow_mode": flow_mode,
"iteration_count": iteration_count,
"at": datetime.now(timezone.utc).isoformat(),
},
}
if selected_actions:
case_metadata["selected_actions"] = selected_actions
if validation:
case_metadata["validation"] = validation
if flow_mode:
case_metadata["flow_mode"] = flow_mode
if routing:
case_metadata["emulator_routing"] = routing
return case_metadata
def build_emulator_response_event(state: AgentState, transaction_id: str) -> TicketResponseEvent:
metadata = state.get("metadata", {}) or {}
request_context = metadata.get("request_context") or {}
case_response = metadata.get("case_response") or request_context.get("case_response")
current_step = str(state.get("current_step") or "")
error_info = state.get("error")
validation = metadata.get("validation") or {}
flow_mode = metadata.get("flow_mode") or request_context.get("flow_mode")
transitions = metadata.get("transitions")
selected_actions = metadata.get("selected_actions") or []
routing = metadata.get("emulator_routing")
emulation_type = request_context.get("type")
iteration_count = state.get("iteration_count", 0)
case_data = metadata.get("case_data") or {}
crm_protocol = _resolve_crm_protocol(case_data)
# Echo persisted values when this run didn't produce new ones so a
# failed run doesn't blank out the previous successful draft.
case_response = _resolve_persisted_field(case_data, case_response, "case_response")
transitions = _resolve_persisted_field(case_data, transitions, "transitions")
cms_status, action = _resolve_outcome(error_info, case_response, flow_mode)
# Per-run scratch only — case-level info goes under top-level `metadata`.
response_metadata: dict = {}
if error_info:
response_metadata["error"] = error_info
case_metadata = _build_case_metadata(
selected_actions=selected_actions,
validation=validation,
flow_mode=flow_mode,
routing=routing,
emulation_type=emulation_type,
iteration_count=iteration_count,
)
return TicketResponseEvent(
transactionId=transaction_id,
processing=Processing(
status=cms_status,
current_step=current_step or str(EmulatorGraphStep.CASE_CLOSED),
action=action,
note=state.get("processing_notes") or None,
crmProtocol=crm_protocol,
case_response=case_response,
transitions=transitions,
metadata=response_metadata or None,
),
metadata=case_metadata,
)