mirror of
https://github.com/hoshikawa2/compass_backoffice.git
synced 2026-07-10 06:14:19 +00:00
first commit
This commit is contained in:
BIN
src/api/.DS_Store
vendored
Normal file
BIN
src/api/.DS_Store
vendored
Normal file
Binary file not shown.
5
src/api/LEGACY_ROUTES_DISABLED.md
Normal file
5
src/api/LEGACY_ROUTES_DISABLED.md
Normal 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
1
src/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API Layer - FastAPI application and routes."""
|
||||
BIN
src/api/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/api/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
0
src/api/dependencies/__init__.py
Normal file
0
src/api/dependencies/__init__.py
Normal file
BIN
src/api/dependencies/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/api/dependencies/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/api/dependencies/__pycache__/logging_context.cpython-313.pyc
Normal file
BIN
src/api/dependencies/__pycache__/logging_context.cpython-313.pyc
Normal file
Binary file not shown.
58
src/api/dependencies/logging_context.py
Normal file
58
src/api/dependencies/logging_context.py
Normal 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")
|
||||
)
|
||||
11
src/api/middleware/__init__.py
Normal file
11
src/api/middleware/__init__.py
Normal 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",
|
||||
]
|
||||
BIN
src/api/middleware/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/api/middleware/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/api/middleware/__pycache__/error_handler.cpython-313.pyc
Normal file
BIN
src/api/middleware/__pycache__/error_handler.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/api/middleware/__pycache__/logging.cpython-313.pyc
Normal file
BIN
src/api/middleware/__pycache__/logging.cpython-313.pyc
Normal file
Binary file not shown.
151
src/api/middleware/error_handler.py
Normal file
151
src/api/middleware/error_handler.py
Normal 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"}]
|
||||
}
|
||||
}
|
||||
)
|
||||
56
src/api/middleware/logging.py
Normal file
56
src/api/middleware/logging.py
Normal 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()
|
||||
12
src/api/schemas/__init__.py
Normal file
12
src/api/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
BIN
src/api/schemas/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/api/schemas/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/api/schemas/__pycache__/abrt_schemas.cpython-313.pyc
Normal file
BIN
src/api/schemas/__pycache__/abrt_schemas.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/api/schemas/__pycache__/anatel_schemas.cpython-313.pyc
Normal file
BIN
src/api/schemas/__pycache__/anatel_schemas.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/api/schemas/__pycache__/emulator_rag_schemas.cpython-313.pyc
Normal file
BIN
src/api/schemas/__pycache__/emulator_rag_schemas.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/api/schemas/__pycache__/imdb_schemas.cpython-313.pyc
Normal file
BIN
src/api/schemas/__pycache__/imdb_schemas.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/api/schemas/__pycache__/portability_schemas.cpython-313.pyc
Normal file
BIN
src/api/schemas/__pycache__/portability_schemas.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/api/schemas/__pycache__/request.cpython-313.pyc
Normal file
BIN
src/api/schemas/__pycache__/request.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/api/schemas/__pycache__/response.cpython-313.pyc
Normal file
BIN
src/api/schemas/__pycache__/response.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/api/schemas/__pycache__/siebel_schemas.cpython-313.pyc
Normal file
BIN
src/api/schemas/__pycache__/siebel_schemas.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/api/schemas/__pycache__/tais_kb_schemas.cpython-313.pyc
Normal file
BIN
src/api/schemas/__pycache__/tais_kb_schemas.cpython-313.pyc
Normal file
Binary file not shown.
16
src/api/schemas/abrt_schemas.py
Normal file
16
src/api/schemas/abrt_schemas.py
Normal 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)")
|
||||
165
src/api/schemas/anatel_response_emulator_schemas.py
Normal file
165
src/api/schemas/anatel_response_emulator_schemas.py
Normal 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
|
||||
313
src/api/schemas/anatel_schemas.py
Normal file
313
src/api/schemas/anatel_schemas.py
Normal 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]
|
||||
26
src/api/schemas/emulator_rag_schemas.py
Normal file
26
src/api/schemas/emulator_rag_schemas.py
Normal 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
|
||||
15
src/api/schemas/imdb_schemas.py
Normal file
15
src/api/schemas/imdb_schemas.py
Normal 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)"
|
||||
)
|
||||
30
src/api/schemas/portability_schemas.py
Normal file
30
src/api/schemas/portability_schemas.py
Normal 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")
|
||||
53
src/api/schemas/request.py
Normal file
53
src/api/schemas/request.py
Normal 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"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
77
src/api/schemas/response.py
Normal file
77
src/api/schemas/response.py
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
230
src/api/schemas/siebel_schemas.py
Normal file
230
src/api/schemas/siebel_schemas.py
Normal 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,
|
||||
},
|
||||
}
|
||||
59
src/api/schemas/tais_kb_schemas.py
Normal file
59
src/api/schemas/tais_kb_schemas.py
Normal 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")
|
||||
BIN
src/api/utils/__pycache__/agent_helpers.cpython-313.pyc
Normal file
BIN
src/api/utils/__pycache__/agent_helpers.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
230
src/api/utils/agent_helpers.py
Normal file
230
src/api/utils/agent_helpers.py
Normal 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
|
||||
)
|
||||
)
|
||||
202
src/api/utils/emulator_response_builder.py
Normal file
202
src/api/utils/emulator_response_builder.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user