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/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