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

Binary file not shown.

View File

@@ -0,0 +1,24 @@
"""
Agent graph definitions.
This module contains LangGraph graph definitions for the agent.
"""
from src.agent.graphs.main_graph import create_main_agent_graph
from src.agent.graphs.emulator_graph import create_emulator_graph
# Mapa `event_type` → factory de grafo. Consultado pelo lazy singleton em
# `app.state.get_or_create_graph(event_type)` para compilar o grafo certo
# na primeira mensagem de cada tipo.
GRAPH_FACTORIES = {
"checklist": lambda: create_main_agent_graph(tools=None),
"response_emulator": create_emulator_graph,
}
__all__ = [
"create_main_agent_graph",
"create_emulator_graph",
"GRAPH_FACTORIES",
]

View File

@@ -0,0 +1,162 @@
"""Response Emulator graph.
Triggered synchronously by the REST routes. `metadata.flow_mode`
(set by the route) selects the path:
flow_mode="generate" start → fetch_case → validate_actions → router
→ retrieves → generate → validate_response
→ persist_draft → END
flow_mode="approve" start → fetch_case → approve_draft → END
flow_mode="close" start → fetch_case → close_case → END
Errors in fetch_case / validate_actions / generate_response /
persist_draft / approve_draft / close_case stop the graph via
`should_continue`. Retrieve failures are tolerant (empty list);
validate_response failures only flag sub-status.
"""
from langgraph.graph import END, StateGraph
from src.agent.nodes.emulator import (
approve_draft_node,
close_case_node,
fetch_case_node,
generate_response_node,
persist_draft_node,
retrieve_history_node,
retrieve_templates_node,
router_node,
start_response_emulation_node,
validate_actions_node,
validate_response_node,
)
from src.agent.state.agent_state import AgentState
from src.agent.state.steps_emulator import EmulatorGraphStep
from src.core.logging import get_logger
logger = get_logger(__name__)
def create_emulator_graph() -> StateGraph:
"""Factory consumed by the lazy singleton in `app.state.get_or_create_graph`."""
logger.info("Creating emulator graph")
workflow = StateGraph(AgentState)
async def start_wrapper(state: AgentState) -> AgentState:
return await start_response_emulation_node.start_response_emulation(state)
async def fetch_case_wrapper(state: AgentState) -> AgentState:
return await fetch_case_node.fetch_case(state)
async def validate_actions_wrapper(state: AgentState) -> AgentState:
return await validate_actions_node.validate_actions(state)
async def router_wrapper(state: AgentState) -> AgentState:
return await router_node.route(state)
async def retrieve_templates_wrapper(state: AgentState) -> AgentState:
return await retrieve_templates_node.retrieve_templates(state)
async def retrieve_history_wrapper(state: AgentState) -> AgentState:
return await retrieve_history_node.retrieve_history(state)
async def generate_response_wrapper(state: AgentState) -> AgentState:
return await generate_response_node.generate_response(state)
async def validate_response_wrapper(state: AgentState) -> AgentState:
return await validate_response_node.validate_response(state)
async def persist_draft_wrapper(state: AgentState) -> AgentState:
return await persist_draft_node.persist_draft(state)
async def approve_draft_wrapper(state: AgentState) -> AgentState:
return await approve_draft_node.approve_draft(state)
async def close_case_wrapper(state: AgentState) -> AgentState:
return await close_case_node.close_case(state)
workflow.add_node(EmulatorGraphStep.RESPONSE_EMULATION_START, start_wrapper)
workflow.add_node(EmulatorGraphStep.FETCH_CASE, fetch_case_wrapper)
workflow.add_node(EmulatorGraphStep.VALIDATE_ACTIONS, validate_actions_wrapper)
workflow.add_node(EmulatorGraphStep.ROUTER_DECISION, router_wrapper)
workflow.add_node(EmulatorGraphStep.RETRIEVE_TEMPLATES, retrieve_templates_wrapper)
workflow.add_node(EmulatorGraphStep.RETRIEVE_HISTORY, retrieve_history_wrapper)
workflow.add_node(EmulatorGraphStep.GENERATE_RESPONSE, generate_response_wrapper)
workflow.add_node(EmulatorGraphStep.VALIDATE_RESPONSE, validate_response_wrapper)
workflow.add_node(EmulatorGraphStep.PERSIST_DRAFT, persist_draft_wrapper)
workflow.add_node(EmulatorGraphStep.APPROVE_DRAFT, approve_draft_wrapper)
workflow.add_node(EmulatorGraphStep.CLOSE_CASE, close_case_wrapper)
workflow.set_entry_point(EmulatorGraphStep.RESPONSE_EMULATION_START)
workflow.add_edge(EmulatorGraphStep.RESPONSE_EMULATION_START, EmulatorGraphStep.FETCH_CASE)
def _route_after_fetch(state: AgentState) -> str:
if state.get("error"):
return "failed"
flow_mode = (state.get("metadata") or {}).get("flow_mode")
if flow_mode == "close":
return "close"
if flow_mode == "approve":
return "approve"
return "generate"
workflow.add_conditional_edges(
EmulatorGraphStep.FETCH_CASE,
_route_after_fetch,
{
"generate": EmulatorGraphStep.VALIDATE_ACTIONS,
"approve": EmulatorGraphStep.APPROVE_DRAFT,
"close": EmulatorGraphStep.CLOSE_CASE,
"failed": END,
},
)
workflow.add_conditional_edges(
EmulatorGraphStep.VALIDATE_ACTIONS,
validate_actions_node.should_continue,
{
"continue": EmulatorGraphStep.ROUTER_DECISION,
"failed": END,
},
)
workflow.add_conditional_edges(
EmulatorGraphStep.ROUTER_DECISION,
router_node.next_step_after_router,
{
EmulatorGraphStep.RETRIEVE_TEMPLATES: EmulatorGraphStep.RETRIEVE_TEMPLATES,
EmulatorGraphStep.RETRIEVE_HISTORY: EmulatorGraphStep.RETRIEVE_HISTORY,
EmulatorGraphStep.GENERATE_RESPONSE: EmulatorGraphStep.GENERATE_RESPONSE,
},
)
workflow.add_conditional_edges(
EmulatorGraphStep.RETRIEVE_TEMPLATES,
router_node.next_step_after_templates,
{
EmulatorGraphStep.RETRIEVE_HISTORY: EmulatorGraphStep.RETRIEVE_HISTORY,
EmulatorGraphStep.GENERATE_RESPONSE: EmulatorGraphStep.GENERATE_RESPONSE,
},
)
workflow.add_edge(EmulatorGraphStep.RETRIEVE_HISTORY, EmulatorGraphStep.GENERATE_RESPONSE)
workflow.add_conditional_edges(
EmulatorGraphStep.GENERATE_RESPONSE,
generate_response_node.should_continue,
{
"continue": EmulatorGraphStep.VALIDATE_RESPONSE,
"failed": END,
},
)
workflow.add_edge(EmulatorGraphStep.VALIDATE_RESPONSE, EmulatorGraphStep.PERSIST_DRAFT)
workflow.add_edge(EmulatorGraphStep.PERSIST_DRAFT, END)
workflow.add_edge(EmulatorGraphStep.APPROVE_DRAFT, END)
workflow.add_edge(EmulatorGraphStep.CLOSE_CASE, END)
logger.info("Emulator graph created successfully")
return workflow.compile()

View File

@@ -0,0 +1,88 @@
"""
Two-Step LLM-only agent graph for testing purposes.
This graph skips external API calls and focuses on the new Two-Step Classification architecture:
fetch_ticket -> validation
-> canceling_analysis
├─(cancelar)─> END (with fixed triplet)
└─(continuar)─> reclassification_analysis
├─(tratamento)─> END
└─(reclassificar - motivo incorreto)─> llm_reclassification -> END
Use this to test the separation of canceling logic and category validation logic.
"""
from typing import List, Any, Optional
from langgraph.graph import StateGraph, END
from src.agent.state.agent_state import AgentState
from src.agent.state.steps import GraphStep
from src.agent.nodes import (
validation_node,
fetch_ticket_node,
canceling_analysis_node,
reclassification_analysis_node
)
from src.core.logging import get_logger
logger = get_logger(__name__)
def create_unified_llm_graph() -> StateGraph:
"""
Creates a test graph with the reclassification analysis step.
Flow: fetch -> validation -> canceling_analysis -> tim_complaint_analysis -> routing -> END
"""
logger.info("Creating Unified LLM test graph")
workflow = StateGraph(AgentState)
# ── Node wrappers ──────────────────────────────────────────────────────────
async def fetch_ticket_wrapper(state: AgentState) -> AgentState:
return await fetch_ticket_node.fetch_ticket_data(state)
async def validation_wrapper(state: AgentState) -> AgentState:
return await validation_node.validate_ticket(state)
async def canceling_analysis_wrapper(state: AgentState) -> AgentState:
return await canceling_analysis_node.perform_canceling_analysis(state)
async def reclassification_analysis_wrapper(state: AgentState) -> AgentState:
return await reclassification_analysis_node.perform_reclassification_analysis(state)
# ── Register nodes ─────────────────────────────────────────────────────────
workflow.add_node("fetch_ticket", fetch_ticket_wrapper)
workflow.add_node("validation", validation_wrapper)
workflow.add_node("canceling_analysis", canceling_analysis_wrapper)
workflow.add_node("reclassification_analysis", reclassification_analysis_wrapper)
# ── Entry point ────────────────────────────────────────────────────────────
workflow.set_entry_point("fetch_ticket")
# ── Edges ──────────────────────────────────────────────────────────────────
workflow.add_edge("fetch_ticket", "validation")
workflow.add_conditional_edges(
"validation",
validation_node.should_continue,
{
"continue": "canceling_analysis",
"reject": END,
},
)
workflow.add_conditional_edges(
"canceling_analysis",
lambda state: "reclassification_analysis" if state.get("current_step") == GraphStep.PROCEED_GRAPH else END,
{
"reclassification_analysis": "reclassification_analysis",
END: END,
},
)
workflow.add_edge("reclassification_analysis", END)
logger.info("Unified LLM test graph created successfully")
return workflow.compile()

View File

@@ -0,0 +1,279 @@
"""
Main agent graph definition using LangGraph.
This module defines the primary agent execution graph with nodes,
edges, and conditional routing.
"""
from typing import List, Any, Optional
from langgraph.graph import StateGraph, END
from src.agent.state.agent_state import AgentState, increment_iteration
from src.core.logging import get_logger
import src.agent.nodes as nodes
from src.agent.state.steps import GraphStep
logger = get_logger(__name__)
def create_main_agent_graph(tools: Optional[List[Any]] = None) -> StateGraph:
"""
Create the main agent graph using the reclassification analysis node.
This function constructs a LangGraph StateGraph that fetches the ticket, validates it,
enriches it, performs canceling analysis, does a reclassification analysis (motive/modality),
and opens the ticket in Siebel.
"""
logger.info("Creating agent graph")
# Create the graph
workflow = StateGraph(AgentState)
# Define node functions with proper signatures
async def fetch_ticket_wrapper(state: AgentState) -> AgentState:
"""Fetch ticket data from CMS."""
increment_iteration(state)
return await nodes.fetch_ticket_node.fetch_ticket_data(state)
async def validation_node_wrapper(state: AgentState) -> AgentState:
"""Validate ticket requirements."""
return await nodes.validation_node.validate_ticket(state)
async def imdb_enrichment_node_wrapper(state: AgentState) -> AgentState:
"""Enriches data with IMDB info"""
return await nodes.imdb_enrichment_node.imdb_enrich_ticket(state)
async def speech_enrichment_node_wrapper(state: AgentState) -> AgentState:
"""Wrapper for speech analytics enrichment node"""
return await nodes.speech_enrichment_node.enrich_with_speech(state)
async def knowledge_base_enrichment_node_wrapper(state: AgentState) -> AgentState:
"""Wrapper for TAIS knowledge base enrichment node"""
return await nodes.knowledge_base_enrichment_node.enrich_with_knowledge_base(state)
async def bypass_rules_node_wrapper(state: AgentState) -> AgentState:
"""Evaluates bypass rules for cancelamento/reclassificação/reencaminhamento."""
return await nodes.bypass_rules_node.evaluate_bypass_rules(state)
async def identity_verification_node_wrapper(state: AgentState) -> AgentState:
"""Deterministic identity verification (CPFs vs Selo GOV BR vs anexo)"""
return await nodes.identity_verification_node.perform_identity_verification(state)
async def canceling_analysis_node_wrapper(state: AgentState) -> AgentState:
"""Wrapper for canceling analysis node"""
return await nodes.canceling_analysis_node.perform_canceling_analysis(state)
async def tim_complaint_analysis_node_wrapper(state: AgentState) -> AgentState:
"""Analyzes if complaint is regarding Tim"""
return await nodes.tim_complaint_analysis_node.perform_tim_complaint_analysis(state)
async def different_complaint_operator_node_wrapper(state: AgentState) -> AgentState:
"""Forwarding conditions if complaint is regarding different operator"""
return await nodes.different_complaint_operator_node.perform_different_operator(state)
async def undefined_complaint_operator_node_wrapper(state: AgentState) -> AgentState:
"""Forwarding conditions if complaint operator is undefined"""
return await nodes.undefined_complaint_operator_node.perform_undefined_complaint(state)
async def tim_complaint_node_wrapper(state: AgentState) -> AgentState:
"""Forwarding conditions if complaint operator is TIM"""
return await nodes.tim_complaint_node.handle_tim_complaint(state)
async def reclassification_analysis_node_wrapper(state: AgentState) -> AgentState:
"""Wrapper for reclassification analysis node"""
return await nodes.reclassification_analysis_node.perform_reclassification_analysis(state)
async def siebel_sr_opening_node_wrapper(state: AgentState) -> AgentState:
"""Opens a Siebel SR with the respective llm classification (canceling, reclassification, forwarding)"""
return await nodes.siebel_sr_opening_node.open_siebel_sr(state)
async def treatment_decision_node_wrapper(state: AgentState) -> AgentState:
"""Wrapper for treatment decision node — routes to IA agent or SMART_HUMAN"""
return await nodes.treatment_decision_node.treatment_decision(state)
async def cache_check_node_wrapper(state: AgentState) -> AgentState:
"""Wrapper for memory cache lookup node"""
return await nodes.cache_check_node.check_cache_node(state)
# Add nodes to the graph
workflow.add_node("fetch_ticket", fetch_ticket_wrapper)
workflow.add_node(GraphStep.VALIDATION, validation_node_wrapper)
workflow.add_node(GraphStep.BYPASS_RULES, bypass_rules_node_wrapper)
workflow.add_node(GraphStep.CACHE_CHECK, cache_check_node_wrapper)
workflow.add_node(GraphStep.IMDB_ENRICHMENT, imdb_enrichment_node_wrapper)
workflow.add_node(GraphStep.IDENTITY_VERIFICATION, identity_verification_node_wrapper)
workflow.add_node(GraphStep.SPEECH_ENRICHMENT, speech_enrichment_node_wrapper)
workflow.add_node("knowledge_base_enrichment", knowledge_base_enrichment_node_wrapper)
workflow.add_node(GraphStep.CANCELING_ANALYSIS, canceling_analysis_node_wrapper)
workflow.add_node(GraphStep.TIM_COMPLAINT_ANALYSIS, tim_complaint_analysis_node_wrapper)
workflow.add_node("different_complaint_operator", different_complaint_operator_node_wrapper)
workflow.add_node("undefined_complaint_operator", undefined_complaint_operator_node_wrapper)
workflow.add_node("tim_complaint", tim_complaint_node_wrapper)
workflow.add_node(GraphStep.RECLASSIFICATION_ANALYSIS, reclassification_analysis_node_wrapper)
workflow.add_node(GraphStep.SIEBEL_SR_OPENING, siebel_sr_opening_node_wrapper)
workflow.add_node(GraphStep.TREATMENT_DECISION, treatment_decision_node_wrapper)
# Set entry point
workflow.set_entry_point("fetch_ticket")
# Add edges
workflow.add_edge("fetch_ticket", GraphStep.VALIDATION)
workflow.add_conditional_edges(
GraphStep.VALIDATION,
nodes.validation_node.should_continue,
{
"continue": GraphStep.BYPASS_RULES,
"reject": END
}
)
workflow.add_edge(GraphStep.BYPASS_RULES, GraphStep.CACHE_CHECK)
def route_after_cache_check(state: AgentState) -> str:
# Cache full hit normally goes to canceling_analysis. With bypass active,
# canceling/reclassification/forwarding já foram pré-populados em
# bypass_rules — vamos direto para treatment_decision.
if state.get("cache_found") is True:
if state.get("bypass_treatment_validations"):
return GraphStep.TREATMENT_DECISION
return GraphStep.CANCELING_ANALYSIS
return GraphStep.IMDB_ENRICHMENT
workflow.add_conditional_edges(
GraphStep.CACHE_CHECK,
route_after_cache_check,
{
GraphStep.TREATMENT_DECISION: GraphStep.TREATMENT_DECISION,
GraphStep.CANCELING_ANALYSIS: GraphStep.CANCELING_ANALYSIS,
GraphStep.IMDB_ENRICHMENT: GraphStep.IMDB_ENRICHMENT,
}
)
workflow.add_conditional_edges(
GraphStep.IMDB_ENRICHMENT,
nodes.imdb_enrichment_node.should_continue,
{
"continue": GraphStep.IDENTITY_VERIFICATION,
"failed": END
}
)
workflow.add_conditional_edges(
GraphStep.IDENTITY_VERIFICATION,
nodes.identity_verification_node.route_after_identity_verification,
{
"proceed": GraphStep.SPEECH_ENRICHMENT,
"cancel": GraphStep.SIEBEL_SR_OPENING,
"smart_human": GraphStep.TREATMENT_DECISION,
"failed": END,
}
)
workflow.add_edge(GraphStep.SPEECH_ENRICHMENT, "knowledge_base_enrichment")
def route_after_knowledge_base(state: AgentState) -> str:
if state.get("bypass_treatment_validations"):
return GraphStep.TREATMENT_DECISION
return GraphStep.CANCELING_ANALYSIS
workflow.add_conditional_edges(
"knowledge_base_enrichment",
route_after_knowledge_base,
{
GraphStep.CANCELING_ANALYSIS: GraphStep.CANCELING_ANALYSIS,
GraphStep.TREATMENT_DECISION: GraphStep.TREATMENT_DECISION,
}
)
def route_after_canceling(state: AgentState) -> str:
step = state.get("current_step")
if step == GraphStep.CANCELING_ANALYSIS_CANCEL_TICKET:
return GraphStep.SIEBEL_SR_OPENING
elif step == GraphStep.PROCEED_GRAPH:
return GraphStep.PROCEED_GRAPH
else:
return END
workflow.add_conditional_edges(
GraphStep.CANCELING_ANALYSIS,
route_after_canceling,
{
GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING,
GraphStep.PROCEED_GRAPH: GraphStep.TIM_COMPLAINT_ANALYSIS,
END: END
}
)
def route_after_tim_complaint_analysis(state: AgentState) -> str:
decision = state.get("metadata", {}).get("request_context", {}).get("is_tim_complaint", "")
if decision == "sim":
return "tim_complaint"
if decision == "não":
return "different_complaint_operator"
elif decision == "inconclusivo":
return "undefined_complaint_operator"
return END
workflow.add_conditional_edges(
GraphStep.TIM_COMPLAINT_ANALYSIS,
route_after_tim_complaint_analysis,
{
"tim_complaint": "tim_complaint",
"different_complaint_operator": "different_complaint_operator",
"undefined_complaint_operator": "undefined_complaint_operator",
END: END
}
)
def route_after_operator_check(state: AgentState) -> str:
context = state.get("metadata", {}).get("request_context", {})
if context.get("forward_complaint"):
return GraphStep.SIEBEL_SR_OPENING
return GraphStep.PROCEED_GRAPH
workflow.add_edge("tim_complaint", GraphStep.RECLASSIFICATION_ANALYSIS)
workflow.add_conditional_edges(
"different_complaint_operator",
route_after_operator_check,
{
GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING,
GraphStep.PROCEED_GRAPH: GraphStep.RECLASSIFICATION_ANALYSIS,
}
)
workflow.add_conditional_edges(
"undefined_complaint_operator",
route_after_operator_check,
{
GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING,
GraphStep.PROCEED_GRAPH: GraphStep.RECLASSIFICATION_ANALYSIS,
}
)
def route_after_reclassification(state: AgentState) -> str:
step = state.get("current_step")
if step == GraphStep.RECLASSIFICATION_ANALYSIS_COMPLETED:
context = state.get("metadata", {}).get("request_context", {})
if context.get("siebel_action") == "reclassificar":
return GraphStep.SIEBEL_SR_OPENING
return GraphStep.PROCEED_GRAPH
else:
return END
workflow.add_conditional_edges(
GraphStep.RECLASSIFICATION_ANALYSIS,
route_after_reclassification,
{
GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING,
GraphStep.PROCEED_GRAPH: GraphStep.TREATMENT_DECISION,
END: END
}
)
workflow.add_edge(GraphStep.TREATMENT_DECISION, GraphStep.SIEBEL_SR_OPENING)
workflow.add_edge(GraphStep.SIEBEL_SR_OPENING, END)
logger.info("Main agent graph created successfully")
# Compile and return the graph
return workflow.compile()

View File

@@ -0,0 +1,21 @@
"""
Executores que disparam os grafos de cada fluxo.
- `process_checklist`: invocado pelo consumer OCI Streaming quando o
envelope traz `event_type="checklist"`. Recebe um `TicketRequestEvent`
validado e o `app.state` do FastAPI.
- `process_response_emulator`: invocado pelas rotas REST do emulador
(POST /case/{tx}/response-emulator/{generate,finalize}). Recebe um
`ResponseEmulatorRequestEvent` montado pela rota e o `app.state`. O
`flow_mode` (`"generate"`, `"approve"` ou `"close"`) seleciona o
caminho dentro do grafo.
"""
from src.api.executors.checklist import process_checklist
from src.api.executors.response_emulator import process_response_emulator
__all__ = [
"process_checklist",
"process_response_emulator",
]

View File

@@ -0,0 +1,253 @@
"""
Executor do fluxo de checklist (etapa 1).
Recebe um `TicketRequestEvent` validado pelo consumer e roda o grafo
principal (`create_main_agent_graph`). Antes vivia inline no
`lifespan` do FastAPI como `process_incoming_ticket` — foi extraído
para permitir múltiplos executors (um por `event_type` do envelope).
Comportamento idêntico ao original, exceto pelo nome do trace Langfuse,
que passou de `agent-cms-execution` para `agent.cms.checklist`.
"""
import logging
import time
from src.agent.state.agent_state import create_initial_state
from src.agent.state.steps import GraphStep
from src.api.dependencies.logging_context import set_ticket_log_context
from src.api.schemas.anatel_schemas import TicketRequestEvent
from src.api.utils import agent_helpers
from src.core.config import settings
from src.core.logging import clear_context, log_operation
from src.utils.ics_collector import build_noc_latency_metadata, build_noc_metadata
from src.utils.observer import flush_trace
from agent_framework.observer import event as _noc_event
logger = logging.getLogger("agent-service")
async def process_checklist(event: TicketRequestEvent, app_state) -> None:
"""Roda o grafo de checklist para um TicketRequestEvent vindo da fila."""
try:
from langfuse import get_client, propagate_attributes
from langfuse.langchain import CallbackHandler
import opentelemetry.context as otel_ctx
except ImportError:
logger.warning("Tracing dependencies missing, running without root trace")
state = create_initial_state(session_id=event.transactionId)
state["metadata"]["transaction_id"] = event.transactionId
state["metadata"]["request_context"] = event.model_dump()
state["metadata"]["_oci_producer"] = getattr(app_state, "oci_producer", None)
agent_graph = await app_state.get_or_create_graph("checklist")
await agent_graph.ainvoke(state)
return
try:
from src.utils.ics_collector import ICsCollector
start = time.time()
payload = event.model_dump()
complaint = payload.get("complaint", {})
customer = payload.get("customer", {})
transaction_id = payload.get("transactionId")
user_id = payload.get("origin", {}).get("submittedBy", {}).get("userId", "unknown")
set_ticket_log_context(event)
logger.info(
"execute_agent started",
extra={
"operation": {"name": "execute_agent", "status": "started"},
"component": "agent_consumer",
"transaction_id": transaction_id,
"case_type": payload.get("caseType"),
},
)
ICsCollector.start(transaction_id)
response_event = None
final_state = None
send_error = None
lf = get_client()
ticket_tags = agent_helpers.build_ticket_tags(payload, complaint)
with propagate_attributes(
session_id=transaction_id,
user_id=user_id,
trace_name="agent.cms.checklist",
tags=ticket_tags,
metadata={
"transactionId": transaction_id,
"caseType": payload.get("caseType"),
"govBrSeal": str(customer.get("govBrSeal")),
"complaintProtocol": complaint.get("complaintProtocol"),
"service": complaint.get("service"),
"modality": complaint.get("modality"),
"motive": complaint.get("motive"),
},
):
with lf.start_as_current_observation(
as_type="agent",
name="agent.cms.checklist",
input=payload,
) as obs:
current_otel_ctx = otel_ctx.get_current()
token = otel_ctx.attach(current_otel_ctx)
try:
with lf.start_as_current_observation(
as_type="span",
name="cms-queue-receive",
input={"raw_event": payload},
) as recv_obs:
recv_obs.update(output=payload)
state = create_initial_state(session_id=transaction_id)
state["metadata"]["transaction_id"] = transaction_id
state["metadata"]["request_context"] = payload
state["metadata"]["_oci_producer"] = getattr(app_state, "oci_producer", None)
agent_graph = await app_state.get_or_create_graph("checklist")
lf_handler = CallbackHandler()
final_state = await agent_graph.ainvoke(
state,
config={"callbacks": [lf_handler]},
)
final_state.get("metadata", {}).pop("_oci_producer", None)
final_state.get("metadata", {}).pop("transaction_id", None)
response_event = agent_helpers.build_cms_response_event(final_state, transaction_id)
current_step = final_state.get("current_step", "unknown")
error_info = final_state.get("error")
outcome_tag = agent_helpers.resolve_outcome_tag(current_step, error_info)
ticket_tags.append(outcome_tag)
if error_info or current_step == GraphStep.VALIDATION_FAILED:
obs.update(
output=response_event.model_dump(),
level="ERROR",
status_message=f"[{error_info.get('type', 'Error') if error_info else 'ValidationError'}] {error_info.get('message', '') if error_info else current_step}",
tags=ticket_tags,
)
else:
obs.update(
output=response_event.model_dump(),
tags=ticket_tags,
)
try:
from src.infrastructure.oci.autonomous.memory_manager import save_state_to_memory
async with log_operation(
"save_state_to_memory",
component="memory",
logger=logger,
):
await save_state_to_memory(final_state)
except Exception:
pass
_noc_event(
"NOC.006",
{
"status": "Flow completed",
"type": "INFO",
**build_noc_latency_metadata(
final_state,
"NOC.006",
latency_ms=int((time.time() - start) * 1000),
),
},
metadata={"noc": True},
)
if settings.ENABLE_OCI_STREAMING and getattr(app_state, "oci_producer", None) and response_event:
with lf.start_as_current_observation(
as_type="span",
name="cms-queue-respond",
input=response_event.model_dump(),
) as resp_obs:
try:
await app_state.oci_producer.send_response(response_event)
end = time.time()
processing_time = round(end - start, 4)
logger.info(
"Response sent back to CMS",
extra={
"operation": {
"name": "cms_response_send",
"status": "success",
"execution_time": processing_time,
},
"component": "agent_consumer",
"transaction_id": transaction_id,
},
)
resp_obs.update(output={"response_event": response_event, "elapsed_seconds": processing_time})
except Exception as send_exc:
send_error = send_exc
resp_obs.update(
level="ERROR",
status_message=f"[{type(send_exc).__name__}] {send_exc}",
output={"sent": False, "error": str(send_exc)},
)
logger.error(
f"Failed to send response back to CMS: {send_exc}",
extra={
"operation": {"name": "cms_response_send", "status": "failed"},
"component": "agent_consumer",
"transaction_id": transaction_id,
},
exc_info=True,
)
finally:
flush_trace()
otel_ctx.detach(token)
execution_time = round(time.time() - start, 4)
logger.info(
"execute_agent completed",
extra={
"operation": {
"name": "execute_agent",
"status": "success",
"execution_time": execution_time,
},
"component": "agent_consumer",
"transaction_id": transaction_id,
},
)
if send_error is not None:
return
except Exception as e:
_noc_event(
"NOC.005",
{
"status": "Fatal exception",
"type": "FAILURE",
**build_noc_metadata(final_state, "NOC.005"),
},
metadata={"noc": True},
)
execution_time = round(time.time() - start, 4) if "start" in locals() else None
logger.error(
f"execute_agent failed: {e}",
extra={
"operation": {
"name": "execute_agent",
"status": "failed",
"execution_time": execution_time,
"error_type": type(e).__name__,
},
"component": "agent_consumer",
"transaction_id": transaction_id if "transaction_id" in locals() else None,
},
exc_info=True,
)
if settings.ENABLE_OCI_STREAMING and getattr(app_state, "oci_producer", None) and final_state is not None:
error_response = agent_helpers.build_cms_response_event(final_state, transaction_id)
try:
await app_state.oci_producer.send_response(error_response)
except Exception:
logger.error("Failed to send crash response", exc_info=True)
finally:
clear_context()

View File

@@ -0,0 +1,262 @@
"""Response Emulator executor: runs the graph + Langfuse tracing.
`event.flow_mode` selects the path inside the graph:
"generate" → fetch_case → ... → validate_response → persist_draft
"close" → fetch_case → close_case (DB done, OCI publish, Siebel SR close)
"""
import logging
import time
from datetime import datetime, timezone
from typing import Optional
from src.agent.state.agent_state import AgentState, create_initial_state
from src.api.schemas.anatel_response_emulator_schemas import ResponseEmulatorRequestEvent
from src.api.schemas.anatel_schemas import ProcessingStatus
from src.api.utils.emulator_response_builder import build_emulator_response_event
from src.core.config import settings
from src.core.logging import clear_context
from src.infrastructure.oci.autonomous.connection import db_manager
from src.utils.observer import flush_trace
logger = logging.getLogger("agent-service")
async def _mark_failed_in_db(transaction_id: str, error_message: str) -> None:
"""Safety net: stamp `processing.status="failed"` when the executor aborts
before publishing the terminal event.
`start_response_emulation_node` flips the case to `processing` /
`processing_regeneration` before the graph runs. If a fatal exception
blows up before the OCI terminal publish, the CMS never receives the
failure event and the doc would stay stuck on the in-flight status —
leaving the GET polling spinning forever. We write the failed sentinel
directly here so the next GET surfaces it.
"""
if db_manager.db is None:
logger.warning(
"Autonomous DB unavailable — cannot mark failed (transaction_id=%s)",
transaction_id,
)
return
try:
coll = db_manager.db[settings.AUTONOMOUS_NOSQL_COLLECTION]
await coll.update_one(
{"transactionId": transaction_id},
{
"$set": {
"processing.status": ProcessingStatus.FAILED.value,
"processing.metadata.error": {
"type": "ExecutorFatal",
"message": error_message,
},
"processing.failed_at": datetime.now(timezone.utc),
}
},
)
logger.info(
"Marked processing.status=failed in DB (executor fatal) | transaction_id=%s",
transaction_id,
)
except Exception as exc:
logger.warning(
"Failed to mark processing.status=failed: %s | transaction_id=%s",
exc, transaction_id, exc_info=True,
)
async def _publish_final_response(app_state, transaction_id: str, final_state: AgentState) -> dict | None:
"""Publishes the terminal TicketResponseEvent to the OCI Response Stream.
Mirrors the checklist executor: this is the LAST message for the case,
so the CMS callback resolves the final DB state without races against
earlier ProgressEvents. Returns the event dump for tracing, or None
when publish was skipped (dry_run / producer unavailable).
"""
final_metadata = final_state.get("metadata") or {}
if final_metadata.get("dry_run"):
return None
producer = getattr(app_state, "oci_producer", None)
if producer is None:
logger.info(
"oci_producer unavailable — skipping emulator final publish (transaction_id=%s)",
transaction_id,
)
return None
response_event = build_emulator_response_event(final_state, transaction_id)
try:
await producer.send_response(response_event)
logger.info(
"Emulator TicketResponseEvent published | transaction_id=%s | status=%s",
transaction_id,
response_event.processing.status,
)
except Exception:
# Match checklist behaviour: log and continue; do not propagate.
logger.exception(
"Failed to publish emulator TicketResponseEvent (transaction_id=%s)",
transaction_id,
)
return None
return response_event.model_dump()
def _build_emulator_state(
event: ResponseEmulatorRequestEvent,
app_state,
) -> AgentState:
state = create_initial_state(session_id=event.transactionId)
state["metadata"]["transaction_id"] = event.transactionId
state["metadata"]["request_context"] = event.model_dump()
state["metadata"]["selected_actions"] = [a.model_dump() for a in event.selected_actions]
state["metadata"]["flow_mode"] = event.flow_mode
state["metadata"]["_oci_producer"] = getattr(app_state, "oci_producer", None)
return state
async def process_response_emulator(
event: ResponseEmulatorRequestEvent,
app_state,
) -> Optional[AgentState]:
"""Runs the emulator graph; returns final state on success, None on failure."""
try:
from langfuse import get_client, propagate_attributes
from langfuse.langchain import CallbackHandler
import opentelemetry.context as otel_ctx
except ImportError:
logger.warning("Tracing dependencies missing, running without root trace")
state = _build_emulator_state(event, app_state)
emulator_graph = await app_state.get_or_create_graph("response_emulator")
final_state = await emulator_graph.ainvoke(state)
await _publish_final_response(app_state, event.transactionId, final_state)
return final_state
transaction_id = event.transactionId
payload = event.model_dump()
start = time.time()
try:
logger.info(
"response_emulator started",
extra={
"operation": {"name": "response_emulator", "status": "started"},
"component": "agent_consumer",
"transaction_id": transaction_id,
"emulation_type": event.type,
"flow_mode": event.flow_mode,
"selected_actions_count": len(event.selected_actions),
},
)
lf = get_client()
with propagate_attributes(
session_id=transaction_id,
user_id="cms",
trace_name="agent.cms.emulator",
metadata={
"transactionId": transaction_id,
"emulation_type": event.type,
"flow_mode": event.flow_mode,
"selected_actions_count": len(event.selected_actions),
},
):
with lf.start_as_current_observation(
as_type="agent",
name="agent.cms.emulator",
input=payload,
) as obs:
current_otel_ctx = otel_ctx.get_current()
token = otel_ctx.attach(current_otel_ctx)
try:
with lf.start_as_current_observation(
as_type="span",
name="cms-queue-receive",
input={"raw_event": payload},
) as recv_obs:
recv_obs.update(output=payload)
state = _build_emulator_state(event, app_state)
emulator_graph = await app_state.get_or_create_graph("response_emulator")
lf_handler = CallbackHandler()
final_state = await emulator_graph.ainvoke(
state,
config={"callbacks": [lf_handler]},
)
# Publish the terminal event BEFORE scrubbing metadata so
# the producer (set on app_state) is reachable and the
# event is the last message on the response stream.
response_event_dump = await _publish_final_response(
app_state, transaction_id, final_state
)
final_state.get("metadata", {}).pop("_oci_producer", None)
final_state.get("metadata", {}).pop("transaction_id", None)
error_info = final_state.get("error")
current_step = final_state.get("current_step", "unknown")
if error_info:
obs.update(
output={
"current_step": current_step,
"response_event": response_event_dump,
"error": error_info,
},
level="ERROR",
status_message=f"[{error_info.get('type', 'Error')}] {error_info.get('message', '')}",
)
else:
obs.update(
output={
"current_step": current_step,
"response_event": response_event_dump,
"validation": final_state.get("metadata", {}).get("validation"),
}
)
finally:
flush_trace()
otel_ctx.detach(token)
elapsed = round(time.time() - start, 4)
logger.info(
"response_emulator completed",
extra={
"operation": {
"name": "response_emulator",
"status": "success",
"execution_time": elapsed,
},
"component": "agent_consumer",
"transaction_id": transaction_id,
},
)
return final_state
except Exception as e:
elapsed = round(time.time() - start, 4)
logger.error(
f"response_emulator failed: {e}",
extra={
"operation": {
"name": "response_emulator",
"status": "failed",
"execution_time": elapsed,
"error_type": type(e).__name__,
},
"component": "agent_consumer",
"transaction_id": transaction_id,
},
exc_info=True,
)
# Safety net: unblock the GET polling from the in-flight `processing`
# sentinel set by start_response_emulation_node. Best-effort — log
# only if it also fails; the original exception is what matters.
await _mark_failed_in_db(transaction_id, f"[{type(e).__name__}] {e}")
return None
finally:
clear_context()

View File

@@ -0,0 +1,454 @@
"""
FastAPI application factory.
This module creates and configures the FastAPI application with all
necessary middleware, routes, and settings.
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from src.core.config import settings
from src.core.logging import setup_logging
# Configure logging singleton FIRST so third-party libs initialised below
# (OCI, Langfuse, uvicorn) inherit the configured root handler/format.
logger = setup_logging(log_level=settings.LOG_LEVEL, log_format=settings.LOG_FORMAT)
from src.infrastructure.streaming.consumer import OciConsumer
from src.infrastructure.streaming.producer import OciProducer
from src.infrastructure.streaming.debug_producer import LocalDebugProducer
import asyncio
# Initialize Langfuse SDK + Observer pipeline EARLY so 'from agent_framework.observer import event as _noc_event' picks up patched ones
from src.utils.observer import setup_observer
setup_observer()
from agent_framework.observer import event as _noc_event
from src.utils.ics_collector import build_anatel_entry_fields, build_ic_payload, build_noc_metadata, build_noc_latency_metadata
from src.agent.graphs.main_graph import create_main_agent_graph
from src.agent.state.agent_state import create_initial_state
from src.agent.state.steps import GraphStep
from src.api.dependencies.logging_context import set_ticket_log_context
from src.api.schemas.anatel_schemas import TicketRequestEvent
from src.agent.graphs import GRAPH_FACTORIES
from src.api.executors import process_checklist, process_response_emulator
from src.infrastructure.oci.autonomous import db_manager
@asynccontextmanager
async def lifespan(app: FastAPI):
# Re-apply after uvicorn's dictConfig runs and resets propagate on its loggers.
import logging
for name in ("uvicorn", "uvicorn.access"):
logging.getLogger(name).propagate = False
logger.info(f"Starting {settings.APP_NAME} v{settings.VERSION}")
try:
await db_manager.connect()
except Exception:
logger.error("Database initialization failed", exc_info=True)
app.state.agent_graph = create_main_agent_graph(tools=None)
async def process_incoming_ticket(event: TicketRequestEvent) -> None:
# Initialize Autonomous Database (Mongo) for memory cache
try:
from langfuse import get_client, propagate_attributes
from langfuse.langchain import CallbackHandler
import opentelemetry.context as otel_ctx
except ImportError:
logger.warning("Tracing dependencies missing, running without root trace")
state = create_initial_state(session_id=event.transactionId)
state["metadata"]["transaction_id"] = event.transactionId
state["metadata"]["request_context"] = event.model_dump()
state["metadata"]["_oci_producer"] = getattr(app.state, "oci_producer", None)
final_state = await app.state.agent_graph.ainvoke(state)
return
try:
from src.utils.ics_collector import ICsCollector
start = time.time()
payload = event.model_dump()
complaint = payload.get("complaint", {})
customer = payload.get("customer", {})
transaction_id = payload.get("transactionId")
user_id = payload.get("origin", {}).get("submittedBy", {}).get("userId", "unknown")
set_ticket_log_context(event)
logger.info(
"execute_agent started",
extra={
"operation": {"name": "execute_agent", "status": "started"},
"component": "agent_consumer",
"transaction_id": transaction_id,
"case_type": payload.get("caseType"),
},
)
ICsCollector.start(transaction_id)
# Create root trace in Langfuse
response_event = None
final_state = None
send_error = None
lf = get_client()
ticket_tags = agent_helpers.build_ticket_tags(payload, complaint)
with propagate_attributes(
session_id=transaction_id,
user_id=user_id,
trace_name="agent-cms-execution",
tags=ticket_tags,
metadata={
"transactionId": transaction_id,
"caseType": payload.get("caseType"),
"govBrSeal": str(customer.get("govBrSeal")),
"complaintProtocol": complaint.get("complaintProtocol"),
"service": complaint.get("service"),
"modality": complaint.get("modality"),
"motive": complaint.get("motive")
},
):
with lf.start_as_current_observation(
as_type="agent",
name="agent-cms-execution",
input=payload,
) as obs:
current_otel_ctx = otel_ctx.get_current()
token = otel_ctx.attach(current_otel_ctx)
try:
with lf.start_as_current_observation(
as_type="span",
name="cms-queue-receive",
input={"raw_event": payload},
) as recv_obs:
recv_obs.update(output=payload)
state = create_initial_state(session_id=transaction_id)
state["metadata"]["transaction_id"] = transaction_id
state["metadata"]["request_context"] = payload
state["metadata"]["_oci_producer"] = getattr(app.state, "oci_producer", None)
lf_handler = CallbackHandler()
final_state = await app.state.agent_graph.ainvoke(
state,
config={"callbacks": [lf_handler]}
)
# Strip injected runtime references before downstream consumers serialize state.
final_state.get("metadata", {}).pop("_oci_producer", None)
final_state.get("metadata", {}).pop("transaction_id", None)
response_event = agent_helpers.build_cms_response_event(final_state, transaction_id)
current_step = final_state.get("current_step", "unknown")
error_info = final_state.get("error")
outcome_tag = agent_helpers.resolve_outcome_tag(current_step, error_info)
ticket_tags.append(outcome_tag)
if error_info or current_step == GraphStep.VALIDATION_FAILED:
obs.update(
output=response_event.model_dump(),
level="ERROR",
status_message=f"[{error_info.get('type', 'Error') if error_info else 'ValidationError'}] {error_info.get('message', '') if error_info else current_step}",
tags=ticket_tags,
)
else:
obs.update(
output=response_event.model_dump(),
tags=ticket_tags,
)
try:
from src.infrastructure.oci.autonomous.memory_manager import save_state_to_memory
async with log_operation(
"save_state_to_memory",
component="memory",
logger=logger,
):
await save_state_to_memory(final_state)
except Exception:
# log_operation already emitted a failed log with exc_info; swallow to keep
# response delivery on the happy path independent of cache health.
pass
_noc_event("NOC.006", {
"status": "Flow completed",
"type": "INFO",
**build_noc_latency_metadata(
final_state, "NOC.006",
latency_ms=int((time.time() - start) * 1000)
),
}, metadata={"noc": True})
if settings.ENABLE_OCI_STREAMING and hasattr(app.state, 'oci_producer') and response_event:
with lf.start_as_current_observation(
as_type="span",
name="cms-queue-respond",
input=response_event.model_dump(),
) as resp_obs:
try:
await app.state.oci_producer.send_response(response_event)
end = time.time()
processing_time = round(end - start, 4)
logger.info(
"Response sent back to CMS",
extra={
"operation": {
"name": "cms_response_send",
"status": "success",
"execution_time": processing_time,
},
"component": "agent_consumer",
"transaction_id": transaction_id,
},
)
resp_obs.update(output={"response_event": response_event, "elapsed_seconds": processing_time})
except Exception as send_exc:
send_error = send_exc
resp_obs.update(
level="ERROR",
status_message=f"[{type(send_exc).__name__}] {send_exc}",
output={"sent": False, "error": str(send_exc)},
)
logger.error(
f"Failed to send response back to CMS: {send_exc}",
extra={
"operation": {"name": "cms_response_send", "status": "failed"},
"component": "agent_consumer",
"transaction_id": transaction_id,
},
exc_info=True,
)
finally:
flush_trace()
otel_ctx.detach(token)
execution_time = round(time.time() - start, 4)
logger.info(
"execute_agent completed",
extra={
"operation": {
"name": "execute_agent",
"status": "success",
"execution_time": execution_time,
},
"component": "agent_consumer",
"transaction_id": transaction_id,
},
)
if send_error is not None:
return
except Exception as e:
_noc_event("NOC.005", {
"status": "Fatal exception",
"type": "FAILURE",
**build_noc_metadata(final_state, "NOC.005"),
}, metadata={"noc": True})
_noc_event("AGA.009", {
"status": f"Falha no acionamento do Agente: [{type(e).__name__}] {str(e)}",
"type": "FAILURE",
"session_id": transaction_id if "transaction_id" in locals() else "N/A",
"tag": "AGA.009",
"call_id": transaction_id if "transaction_id" in locals() else "N/A",
"origin": "AGENT",
**build_ic_payload(final_state, "AGA.009", build_anatel_entry_fields(final_state)),
}, metadata={"noc": True})
execution_time = round(time.time() - start, 4) if "start" in locals() else None
logger.error(
f"execute_agent failed: {e}",
extra={
"operation": {
"name": "execute_agent",
"status": "failed",
"execution_time": execution_time,
"error_type": type(e).__name__,
},
"component": "agent_consumer",
"transaction_id": transaction_id if "transaction_id" in locals() else None,
},
exc_info=True,
)
if settings.ENABLE_OCI_STREAMING and hasattr(app.state, 'oci_producer') and final_state is not None:
error_response = agent_helpers.build_cms_response_event(final_state, transaction_id)
try:
await app.state.oci_producer.send_response(error_response)
except Exception:
logger.error("Failed to send crash response", exc_info=True)
finally:
clear_context()
# Lazy singleton: cada grafo só é compilado na primeira mensagem do seu
# tipo. Pods que só recebem checklist nunca pagam pelo emulator, e
# vice-versa. Um Lock por event_type evita compilação dupla em corridas.
app.state.graphs_cache: dict = {}
app.state.graphs_locks: dict = {}
async def get_or_create_graph(event_type: str):
cached = app.state.graphs_cache.get(event_type)
if cached is not None:
return cached
lock = app.state.graphs_locks.setdefault(event_type, asyncio.Lock())
async with lock:
cached = app.state.graphs_cache.get(event_type)
if cached is not None:
return cached
factory = GRAPH_FACTORIES.get(event_type)
if factory is None:
raise ValueError(f"No graph factory for event_type={event_type}")
logger.info(f"Compiling graph on first use: {event_type}")
graph = factory()
app.state.graphs_cache[event_type] = graph
return graph
app.state.get_or_create_graph = get_or_create_graph
# Os dois fluxos chegam pelo streaming OCI (envelope discriminado por
# event_type). As rotas REST do emulador continuam disponíveis para
# disparo síncrono direto.
dispatcher = {
"checklist": lambda ev: process_checklist(ev, app.state),
"response_emulator": lambda ev: process_response_emulator(ev, app.state),
}
if settings.ENABLE_OCI_STREAMING:
try:
app.state.oci_producer = OciProducer(settings.OCI_RESPONSE_STREAM_OCID)
app.state.oci_consumer = OciConsumer(
settings.OCI_REQUEST_STREAM_OCID,
settings.OCI_CONSUMER_GROUP_NAME,
)
app.state.streaming_task = asyncio.create_task(
app.state.oci_consumer.start(dispatcher)
)
logger.info("OCI Streaming components initialized")
except Exception:
logger.error("Streaming initialization failed", exc_info=True)
else:
logger.warning("Streaming disabled")
if settings.DEBUG:
app.state.oci_producer = LocalDebugProducer()
logger.info("LocalDebugProducer ativo — eventos gravados em debug_events.jsonl")
yield
if settings.ENABLE_OCI_STREAMING:
if hasattr(app.state, 'oci_consumer'):
app.state.oci_consumer.stop()
if hasattr(app.state, 'streaming_task'):
await app.state.streaming_task
logger.info("Streaming components stopped")
try:
await db_manager.close()
except Exception:
logger.warning("Failed to close Autonomous DB connection cleanly", exc_info=True)
def create_app() -> FastAPI:
"""
Create and configure the FastAPI application.
This factory function:
1. Creates FastAPI instance with metadata
2. Configures CORS middleware
3. Adds custom middleware (logging, error handling)
4. Registers route handlers
Returns:
Configured FastAPI application instance
"""
app = FastAPI(
lifespan=lifespan,
title=settings.APP_NAME,
version=settings.VERSION,
description="Agent Microservice built with LangGraph and LangChain",
docs_url="/docs" if settings.DEBUG else None,
redoc_url="/redoc" if settings.DEBUG else None,
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Add custom middleware
from src.api.middleware import LoggingMiddleware, ErrorHandlerMiddleware
app.add_middleware(LoggingMiddleware)
app.add_middleware(ErrorHandlerMiddleware)
# Register routes
from src.api.routes import health, agent, emulator, emulator_rag
app.include_router(health.router, prefix="/health", tags=["health"])
app.include_router(agent.router, prefix="/agent", tags=["agent"])
app.include_router(emulator.router, prefix="/case", tags=["emulator"])
app.include_router(emulator_rag.router, prefix="/emulator-rag", tags=["emulator-rag"])
# Add RequestValidationError handler for standardized error format
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
from src.api.schemas.anatel_schemas import ERROR_CODE_MAPPING, ReasonCode
error_messages = []
for err in exc.errors():
# loc usually starts with ('body', ...) for request body errors
loc_tuple = tuple(l for l in err.get("loc", []) if l != "body")
# Special handling for inputChannel and caseType Enum validation
is_enum_error = err.get("type", "").startswith("enum")
target_fields = [("complaint", "inputChannel"), ("caseType",)]
if is_enum_error and loc_tuple in target_fields:
reason_code = ReasonCode.INVALID_VALUE
field_name = loc_tuple[-1]
reason_text = f"Invalid value for field {field_name} or it's not supported yet"
else:
# Try to find a specific code and message in our mapping
mapping_result = ERROR_CODE_MAPPING.get(loc_tuple)
if mapping_result:
reason_code, reason_text = mapping_result
else:
reason_code = ReasonCode.FIELD_ERROR
loc_str = " -> ".join([str(l) for l in err.get("loc", [])])
msg = err.get("msg", "Invalid field")
reason_text = f"{loc_str}: {msg}"
error_messages.append({
"code": reason_code.value,
"text": reason_text
})
# Try to extract correlation_id from body if possible
correlation_id = "unknown"
try:
body = await request.json()
# Swagger v0.0.5 uses transactionId
correlation_id = body.get("transactionId") or body.get("correlation_id") or "unknown"
except Exception:
pass
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
"title": "validation error",
"status": 400,
"correlation_id": correlation_id,
"detail": {
"messages": error_messages
}
}
)
return app
# Create app instance
app = create_app()

View File

@@ -0,0 +1,10 @@
"""
API route modules.
"""
from src.api.routes import agent, health
__all__ = [
"agent",
"health",
]

View File

@@ -0,0 +1,950 @@
import json
import time
import uuid
import os
import traceback
import oci
from fastapi import APIRouter, HTTPException, status, Request, Query
from typing import Annotated, Dict, Any
from fastapi import Depends
from src.api.schemas import AgentRequest, AgentResponse
from src.api.schemas.anatel_schemas import TicketRequestEvent
from src.api.schemas.tais_kb_schemas import TaisKbSearchResponse, TaisKbSearchResultItem
from src.components.clients.tais_kb_client import TaisKbClient, Product
from src.api.dependencies.logging_context import inject_log_context, set_ticket_log_context
from src.agent.state.agent_state import create_initial_state, has_error
from src.agent.state.steps import GraphStep
from src.core.logging import get_logger, log_operation
from src.core.config import settings
from src.utils.ics_collector import ICsCollector, build_anatel_entry_fields, build_ic_payload, build_noc_metadata, build_noc_latency_metadata
from agent_framework.observer import event as _noc_event
from src.utils.observer import flush_trace
from pydantic import BaseModel
from src.api.utils import agent_helpers
from src.api.utils.agent_helpers import create_error_response as _create_error_response
class OciTestResponse(BaseModel):
status: str
generated_text: str | None = None
error_details: dict | None = None
logger = get_logger(__name__)
router = APIRouter()
@router.post(
"/execute",
response_model=AgentResponse,
status_code=status.HTTP_200_OK,
summary="Execute agent",
description="Execute the agent with a user message and return the response"
)
async def execute_agent(request: AgentRequest, fastapi_request: Request) -> AgentResponse:
"""
Execute the agent with a user message.
This endpoint:
1. Validates the incoming request
2. Creates initial agent state
3. Executes the LangGraph agent
4. Returns structured response with metadata
Args:
request: Agent request containing message and optional context
Returns:
Agent response with result and metadata
Raises:
HTTPException: If agent execution fails
Example:
```bash
curl -X POST "http://localhost:8000/agent/execute" \\
-H "Content-Type: application/json" \\
-d '{
"message": "Hello, how can you help me?",
"session_id": "user-123",
"context": {"language": "en"}
}'
```
"""
# Resolve graph via lazy singleton (compila no primeiro uso, reusa em seguida)
agent_graph = await fastapi_request.app.state.get_or_create_graph("checklist")
# Generate session_id if not provided
session_id = request.session_id or str(uuid.uuid4())
logger.info(
"Executing agent",
extra={
"session_id": session_id,
"message_length": len(request.message),
"has_context": bool(request.context),
}
)
# Track execution time
start_time = time.time()
# Start ICs collection and Langfuse root trace
ICsCollector.start(session_id)
start_time = time.time()
try:
from langfuse import get_client, propagate_attributes
from langfuse.langchain import CallbackHandler
import opentelemetry.context as otel_ctx
lf = get_client()
with propagate_attributes(
session_id=session_id,
user_id=request.user_id if getattr(request, "user_id", None) else "unknown",
trace_name="execute-agent",
metadata={"session_id": session_id},
):
with lf.start_as_current_observation(
as_type="agent",
name="execute-agent",
input={"message": request.message, "session_id": session_id},
) as obs:
current_otel_ctx = otel_ctx.get_current()
token = otel_ctx.attach(current_otel_ctx)
try:
# Create initial state
state = create_initial_state(
user_message=request.message,
session_id=session_id
)
# Add context to metadata
state["metadata"]["request_context"] = request.context or {}
state["metadata"]["transaction_id"] = session_id
state["metadata"]["_oci_producer"] = getattr(fastapi_request.app.state, "oci_producer", None)
# Execute the agent graph with CallbackHandler
lf_handler = CallbackHandler()
result_state = await agent_graph.ainvoke(
state,
config={"callbacks": [lf_handler]}
)
result_state.get("metadata", {}).pop("_oci_producer", None)
result_state.get("metadata", {}).pop("transaction_id", None)
# Update observation with output status
current_step = result_state.get("current_step", "unknown")
error_info = result_state.get("error")
obs_output = {
"status": "failed" if error_info or current_step == GraphStep.VALIDATION_FAILED else "completed",
"current_step": current_step,
"error": error_info,
}
if error_info or current_step == GraphStep.VALIDATION_FAILED:
obs.update(
output=obs_output,
level="ERROR",
status_message=f"[{error_info.get('type', 'Error') if error_info else 'ValidationError'}] {error_info.get('message', '') if error_info else current_step}",
)
else:
obs.update(output=obs_output)
_noc_event("NOC.006", {
"status": "Agent flow completed — sending response",
"type": "INFO",
**build_noc_latency_metadata(
result_state, "NOC.006",
latency_ms=int((time.time() - start_time) * 1000)
),
}, metadata={"noc": True})
finally:
otel_ctx.detach(token)
flush_trace()
execution_time_ms = round((time.time() - start_time) * 1000, 2)
final_response, parsed_response = agent_helpers.extract_response_payload(result_state)
# 2. Determine HTTP Status and error flags
status_code = agent_helpers.get_http_status_code(result_state, parsed_response)
is_error = status_code != status.HTTP_200_OK
if is_error:
logger.warning("Agent execution completed with error", extra={"session_id": session_id, "status_code": status_code})
return _create_error_response(status_code, session_id, final_response, parsed_response)
# 3. Success case - build response metadata
state_metadata = result_state.get("metadata", {})
response_metadata = {
"execution_time_ms": round(execution_time_ms, 2),
"iteration_count": result_state.get("iteration_count", 0),
"current_step": result_state.get("current_step", "unknown"),
"tokens_used": state_metadata.get("tokens_used", 0),
"error_count": state_metadata.get("error_count", 0),
}
return AgentResponse(response=final_response, session_id=session_id, metadata=response_metadata)
except ImportError:
# Fallback if dependencies are missing
state = create_initial_state(user_message=request.message, session_id=session_id)
state["metadata"]["request_context"] = request.context or {}
state["metadata"]["transaction_id"] = session_id
state["metadata"]["_oci_producer"] = getattr(fastapi_request.app.state, "oci_producer", None)
result_state = await agent_graph.ainvoke(state)
result_state.get("metadata", {}).pop("_oci_producer", None)
result_state.get("metadata", {}).pop("transaction_id", None)
# (Simplified result handling for fallback)
return AgentResponse(response=result_state.get("final_response", ""), session_id=session_id, metadata={})
except Exception as e:
execution_time_ms = (time.time() - start_time) * 1000
logger.exception(
"Unexpected error during agent execution",
extra={"session_id": session_id, "error": str(e)}
)
noc_state = {
"session_id": session_id,
"metadata": {
"request_context": request.context or {}
}
}
_noc_event("NOC.005", {
"status": f"Fatal agent exception: [{type(e).__name__}] {str(e)}",
"type": "FAILURE",
**build_noc_metadata(noc_state, "NOC.005"),
}, metadata={"noc": True})
_noc_event("AGA.009", {
"status": f"Falha no acionamento do Agente: [{type(e).__name__}] {str(e)}",
"type": "FAILURE",
"session_id": session_id,
"tag": "AGA.009",
"call_id": session_id,
"origin": "AGENT",
**build_ic_payload(noc_state, "AGA.009", build_anatel_entry_fields(noc_state)),
}, metadata={"noc": True})
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Agent execution failed: {str(e)}"
)
@router.post(
"/process-ticket",
status_code=status.HTTP_200_OK,
summary="Process ticket manually",
description="Manually trigger the agent flow with a full TicketRequestEvent (mimics Kafka consumer)"
)
async def process_ticket(fastapi_request: Request, event: Annotated[TicketRequestEvent, Depends(inject_log_context)]):
"""
Process a ticket manually.
"""
try:
agent_graph = await fastapi_request.app.state.get_or_create_graph("checklist")
transaction_id = event.transactionId or f"man-{uuid.uuid4().hex[:8]}"
# Start ICs collection and Langfuse root trace
ICsCollector.start(transaction_id)
context = event.model_dump()
complaint = context.get("complaint", {})
ticket_tags = agent_helpers.build_ticket_tags(context, complaint)
set_ticket_log_context(event, transaction_id)
start_time = time.time()
from langfuse import get_client, propagate_attributes
from langfuse.langchain import CallbackHandler
import opentelemetry.context as otel_ctx
lf = get_client()
with propagate_attributes(
session_id=transaction_id,
user_id=context.get("origin", {}).get("submittedBy", {}).get("userId", "unknown"),
trace_name="process-ticket",
tags=ticket_tags,
metadata={
"transaction_id": transaction_id,
"service": complaint.get("service", ""),
"modality": complaint.get("modality", ""),
"motive": complaint.get("motive", ""),
},
):
with lf.start_as_current_observation(
as_type="agent",
name="process-ticket",
input=context,
) as obs:
current_otel_ctx = otel_ctx.get_current()
token = otel_ctx.attach(current_otel_ctx)
try:
state = create_initial_state(session_id=transaction_id)
state["metadata"]["transaction_id"] = transaction_id
state["metadata"]["request_context"] = context
state["metadata"]["_oci_producer"] = getattr(fastapi_request.app.state, "oci_producer", None)
lf_handler = CallbackHandler()
result_state = await agent_graph.ainvoke(
state,
config={"callbacks": [lf_handler]}
)
result_state.get("metadata", {}).pop("_oci_producer", None)
result_state.get("metadata", {}).pop("transaction_id", None)
response_event = agent_helpers.build_cms_response_event(result_state, transaction_id)
current_step = result_state.get("current_step", "unknown")
error_info = result_state.get("error")
outcome_tag = agent_helpers.resolve_outcome_tag(current_step, error_info)
if error_info or current_step == GraphStep.VALIDATION_FAILED:
obs.update(
output=response_event.model_dump(),
level="ERROR",
status_message=f"[{error_info.get('type', 'Error') if error_info else 'ValidationError'}] {error_info.get('message', '') if error_info else current_step}",
tags=ticket_tags + [outcome_tag],
)
else:
obs.update(
output=response_event.model_dump(),
tags=ticket_tags + [outcome_tag],
)
try:
from src.infrastructure.oci.autonomous.memory_manager import save_state_to_memory
async with log_operation(
"save_state_to_memory",
component="memory",
logger=logger,
):
await save_state_to_memory(result_state)
except Exception as cache_exc:
logger.warning(f"Failed to save to memory cache: {cache_exc}")
_noc_event("NOC.006", {
"status": "Agent flow completed — sending response",
"type": "INFO",
**build_noc_latency_metadata(
result_state, "NOC.006",
latency_ms=int((time.time() - start_time) * 1000)
),
}, metadata={"noc": True})
finally:
otel_ctx.detach(token)
flush_trace()
final_response, parsed_response = agent_helpers.extract_response_payload(result_state)
status_code = agent_helpers.get_http_status_code(result_state, parsed_response)
if status_code != status.HTTP_200_OK:
return _create_error_response(status_code, transaction_id, final_response, parsed_response, response_event.processing.metadata)
# 3. Success case - Return CMS-aligned payload
return {
"message": "Ticket processing completed",
"correlation_id": transaction_id,
"status": response_event.processing.status,
"response": parsed_response,
"fieldsToUpdate": response_event.processing.fieldsToUpdate,
"metadata": response_event.processing.metadata
}
except Exception as e:
logger.exception(
"Error processing manual ticket",
extra={
"correlation_id": transaction_id,
"error": str(e)
}
)
noc_state = {
"session_id": transaction_id,
"metadata": {
"request_context": _context
}
}
_noc_event("NOC.005", {
"status": f"Fatal agent exception: [{type(e).__name__}] {str(e)}",
"type": "FAILURE",
**build_noc_metadata(noc_state, "NOC.005"),
}, metadata={"noc": True})
_noc_event("AGA.009", {
"status": f"Falha no acionamento do Agente: [{type(e).__name__}] {str(e)}",
"type": "FAILURE",
"session_id": transaction_id,
"tag": "AGA.009",
"call_id": transaction_id,
"origin": "AGENT",
**build_ic_payload(noc_state, "AGA.009", build_anatel_entry_fields(noc_state)),
}, metadata={"noc": True})
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Ticket processing failed: {str(e)}"
)
# This is route used to test only LLM calls (excluding IMDB/Siebel).
# NOTE: It still needs TIM's VPN to be connected to function due to the OCI LLM calls.
@router.post(
"/test-llm-pipeline",
status_code=status.HTTP_200_OK,
summary="Test LLM pipeline (no external APIs)",
description=(
"Executes only the LLM-driven nodes (validation → classification → "
"reclassification). Skips IMDB enrichment and Siebel SR opening, "
"so no external API calls are made. Useful for testing prompts and LLM behavior."
),
)
async def test_llm_pipeline(event: Annotated[TicketRequestEvent, Depends(inject_log_context)]):
"""
Runs the ticket through the LLM-only graph, skipping IMDB and Siebel SR opening.
"""
from src.agent.graphs.llm_testing_graph import create_unified_llm_graph
correlation_id = event.transactionId
logger.info(
"Running LLM-only pipeline test",
extra={"correlation_id": correlation_id},
)
# Start ICs collection and Langfuse root trace
ICsCollector.start(correlation_id)
context = event.model_dump()
complaint = context.get("complaint", {})
ticket_tags = agent_helpers.build_ticket_tags(context, complaint)
try:
from langfuse import get_client, propagate_attributes
from langfuse.langchain import CallbackHandler
import opentelemetry.context as otel_ctx
lf = get_client()
with propagate_attributes(
session_id=correlation_id,
user_id=context.get("origin", {}).get("submittedBy", {}).get("userId", "unknown"),
trace_name="test-llm-pipeline",
tags=ticket_tags,
metadata={
"transaction_id": correlation_id,
"service": complaint.get("service", ""),
"modality": complaint.get("modality", ""),
"motive": complaint.get("motive", ""),
},
):
with lf.start_as_current_observation(
as_type="agent",
name="test-llm-pipeline",
input=context,
) as obs:
current_otel_ctx = otel_ctx.get_current()
token = otel_ctx.attach(current_otel_ctx)
try:
llm_graph = create_unified_llm_graph()
state = create_initial_state(session_id=correlation_id)
state["metadata"]["transaction_id"] = correlation_id
state["metadata"]["request_context"] = context
lf_handler = CallbackHandler()
result_state = await llm_graph.ainvoke(
state,
config={"callbacks": [lf_handler]}
)
current_step = result_state.get("current_step", "unknown")
error_state = result_state.get("error")
outcome_tag = agent_helpers.resolve_outcome_tag(current_step, error_state)
obs_output = {
"status": "failed" if error_state or current_step == GraphStep.VALIDATION_FAILED else "completed",
"current_step": current_step,
"error": error_state,
}
if error_state or current_step == GraphStep.VALIDATION_FAILED:
obs.update(
output=obs_output,
level="ERROR",
status_message=f"[{error_state.get('type', 'Error') if error_state else 'ValidationError'}] {error_state.get('message', '') if error_state else current_step}",
tags=ticket_tags + [outcome_tag],
)
else:
obs.update(output=obs_output, tags=ticket_tags + [outcome_tag])
finally:
otel_ctx.detach(token)
flush_trace()
current_step = result_state.get("current_step", "unknown")
ctx = result_state.get("metadata", {}).get("request_context", {})
error_state = result_state.get("error")
# Determine if it's an error
is_error = has_error(result_state) or current_step == GraphStep.VALIDATION_FAILED or current_step == GraphStep.CANCELING_ANALYSIS_FAILED
if is_error:
# Default to 500 for generic classification failures, 400 for validation
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
# Use standard error response
final_response = result_state.get("final_response", str(error_state))
parsed_response = None
if final_response:
try:
parsed_response = json.loads(final_response)
except Exception:
pass
return _create_error_response(status_code, correlation_id, str(final_response), parsed_response)
return {
"correlation_id": correlation_id,
"current_step": current_step,
"siebel_action": ctx.get("siebel_action"),
"reclassification_reason": ctx.get("reclassification_reason"),
"cancel_reason": ctx.get("cancel_reason"),
"reason1": ctx.get("reason1"),
"reason2": ctx.get("reason2"),
"reason3": ctx.get("reason3"),
"error": error_state,
}
except ImportError:
# Fallback
state = create_initial_state(session_id=correlation_id)
llm_graph = create_unified_llm_graph()
result_state = await llm_graph.ainvoke(state)
return {"correlation_id": correlation_id, "current_step": result_state.get("current_step")}
except Exception as e:
logger.exception(
"Error running LLM-only pipeline test",
extra={"correlation_id": correlation_id, "error": str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"LLM pipeline test failed: {str(e)}",
)
@router.get("/test-oci-llm", response_model=OciTestResponse)
async def test_oci_llm(
compartment_id: str = Query(..., description="compartment"),
endpoint_id: str = Query(..., description="dedicated cluster id")
):
config_path = os.environ.get("OCI_CLI_CONFIG_FILE", "/etc/oci/config")
try:
config = oci.config.from_file(file_location=config_path)
generative_ai_client = oci.generative_ai_inference.GenerativeAiInferenceClient(config=config)
llm_request = oci.generative_ai_inference.models.GenerateTextDetails(
compartment_id=compartment_id,
serving_mode=oci.generative_ai_inference.models.DedicatedServingMode(
endpoint_id=endpoint_id
),
inference_request=oci.generative_ai_inference.models.CohereLlmInferenceRequest(
prompt="quanto é 10+10",
max_tokens=200,
temperature=0.0,
is_stream=False
)
)
response = generative_ai_client.generate_text(
generate_text_details=llm_request
)
generated_text = response.data.inference_response.generated_texts[0].text
return OciTestResponse(
status="success",
generated_text=generated_text
)
except Exception as e:
raise HTTPException(
status_code=500,
detail={
"status": "failed",
"error": str(e),
"config_path_attempted": config_path,
"traceback": traceback.format_exc()
}
)
@router.post(
"/test-streaming-producer",
tags=["Test"],
summary="Test OCI Streaming Producer directly (Infrastructure test)"
)
async def test_streaming_producer(transactionId: str, status: str, note: str, fastapi_request: Request):
"""
Sends a test payload directly to CMS via OCI Streaming.
Requires transactionId, status (e.g. 'done', 'failed'), and a note.
"""
from src.api.schemas.anatel_schemas import TicketResponseEvent, Processing
if not settings.ENABLE_OCI_STREAMING:
raise HTTPException(status_code=400, detail="OCI Streaming is disabled in settings")
if not hasattr(fastapi_request.app.state, 'oci_producer'):
raise HTTPException(status_code=500, detail="OCI Producer not initialized in app state")
event = TicketResponseEvent(
transactionId=transactionId,
processing=Processing(
status=status,
current_step="test",
action="atg",
note=note,
metadata={"test": True}
)
)
try:
result = await fastapi_request.app.state.oci_producer.send_response(event)
return {
"status": "success" if result else "failed",
"details": f"Message sent for {transactionId}",
"oci_response_metadata": str(result) if result else None
}
except Exception as e:
logger.error(f"Test streaming failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/process-and-stream",
tags=["Test"],
summary="Process ticket and publish to OCI Stream (Functional test)",
description="Executes the agent graph and PUBLISHES the result to OCI Stream, returning the CMS-aligned format."
)
async def process_and_stream(fastapi_request: Request, event: Annotated[TicketRequestEvent, Depends(inject_log_context)]) -> Dict[str, Any]:
"""
Mimics the full worker flow triggered by an API call.
Executes graph -> calculates status -> publishes to OCI Stream -> returns CMS payload.
"""
from src.api.schemas.anatel_schemas import TicketResponseEvent, Processing
# 1. Execute graph (re-using logic from execute_agent)
agent_graph = await fastapi_request.app.state.get_or_create_graph("checklist")
transaction_id = event.transactionId or f"test-{uuid.uuid4().hex[:8]}"
# Start ICs collection and Langfuse root trace
ICsCollector.start(transaction_id)
payload = event.model_dump()
complaint = payload.get("complaint", {})
customer = payload.get("customer", {})
user_id = payload.get("origin", {}).get("submittedBy", {}).get("userId", "unknown")
set_ticket_log_context(event, transaction_id)
ticket_tags = agent_helpers.build_ticket_tags(payload, complaint)
start_time = time.time()
response_event = None
try:
from langfuse import get_client, propagate_attributes
from langfuse.langchain import CallbackHandler
import opentelemetry.context as otel_ctx
lf = get_client()
with propagate_attributes(
session_id=transaction_id,
user_id=user_id,
trace_name="agent-isolated-execution",
tags=ticket_tags,
metadata={
"agent_version": settings.VERSION,
"transactionId": transaction_id,
"caseType": payload.get("caseType"),
"govBrSeal": customer.get("govBrSeal"),
"complaintProtocol": complaint.get("complaintProtocol"),
"service": complaint.get("service"),
"modality": complaint.get("modality"),
"motive": complaint.get("motive"),
},
):
with lf.start_as_current_observation(
as_type="agent",
name="agent-isolated-execution",
input=payload,
) as obs:
current_otel_ctx = otel_ctx.get_current()
token = otel_ctx.attach(current_otel_ctx)
try:
state = create_initial_state(session_id=transaction_id)
state["metadata"]["transaction_id"] = transaction_id
state["metadata"]["request_context"] = payload
state["metadata"]["_oci_producer"] = getattr(fastapi_request.app.state, "oci_producer", None)
lf_handler = CallbackHandler()
result_state = await agent_graph.ainvoke(
state,
config={"callbacks": [lf_handler]}
)
result_state.get("metadata", {}).pop("_oci_producer", None)
result_state.get("metadata", {}).pop("transaction_id", None)
response_event = agent_helpers.build_cms_response_event(result_state, transaction_id)
current_step = result_state.get("current_step", "unknown")
error_info = result_state.get("error")
outcome_tag = agent_helpers.resolve_outcome_tag(current_step, error_info)
if error_info or current_step == GraphStep.VALIDATION_FAILED:
obs.update(
output=response_event.model_dump(),
level="ERROR",
status_message=f"[{error_info.get('type', 'Error') if error_info else 'ValidationError'}] {error_info.get('message', '') if error_info else current_step}",
tags=ticket_tags + [outcome_tag],
)
else:
obs.update(
output=response_event.model_dump(),
tags=ticket_tags + [outcome_tag],
)
try:
from src.infrastructure.oci.autonomous.memory_manager import save_state_to_memory
except Exception as cache_exc:
logger.warning(f"Failed to save to memory cache: {cache_exc}")
async with log_operation(
"save_state_to_memory",
component="memory",
logger=logger,
):
await save_state_to_memory(result_state)
_noc_event("NOC.006", {
"status": "Agent flow completed — sending response",
"type": "INFO",
**build_noc_latency_metadata(
result_state, "NOC.006",
latency_ms=int((time.time() - start_time) * 1000)
),
}, metadata={"noc": True})
finally:
otel_ctx.detach(token)
lf.flush()
stream_result = None
if settings.ENABLE_OCI_STREAMING and hasattr(fastapi_request.app.state, 'oci_producer'):
stream_result = await fastapi_request.app.state.oci_producer.send_response(response_event)
# 6. Return CMS-aligned payload (Mocking GET /case format)
cms_aligned_response = {
**event.model_dump(),
"transactionId": transaction_id,
"processing": response_event.processing.model_dump(),
"_test_metadata": {
"streaming_sent": bool(stream_result),
"current_step": current_step
}
}
return cms_aligned_response
except Exception as e:
logger.error(f"Process and stream failed: {e}", exc_info=True)
traceback.print_exc()
noc_state = {
"session_id": transaction_id,
"metadata": {
"request_context": payload
}
}
_noc_event("NOC.005", {
"status": f"Fatal agent exception: [{type(e).__name__}] {str(e)}",
"type": "FAILURE",
**build_noc_metadata(noc_state, "NOC.005"),
}, metadata={"noc": True})
_noc_event("AGA.009", {
"status": f"Falha no acionamento do Agente: [{type(e).__name__}] {str(e)}",
"type": "FAILURE",
"session_id": transaction_id,
"tag": "AGA.009",
"call_id": transaction_id,
"origin": "AGENT",
**build_ic_payload(noc_state, "AGA.009", build_anatel_entry_fields(noc_state)),
}, metadata={"noc": True})
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/search-tais-kb",
response_model=TaisKbSearchResponse,
status_code=status.HTTP_200_OK,
summary="Search TAIS Knowledge Base",
description="Search the TAIS knowledge base with semantic search, returning document titles, chunks, and relevance distances."
)
async def search_tais_kb(
query: str = Query(..., description="Search query text"),
product: str = Query("MOVEL", description="Product to search within (MOVEL or FIBRA)"),
segments: list[str] = Query(None, description="Segments to filter by (multiple values allowed)"),
sub_segments: list[str] = Query(None, description="Sub-segments to filter by (multiple values allowed)"),
top_k: int = Query(5, ge=1, le=100, description="Number of documents to return (1-100)"),
check_expiration_date: bool = Query(True, description="Whether to exclude expired documents"),
preprocess: bool = Query(True, description="Whether to preprocess the query with OCI GenAI before searching (default: true)"),
postprocess: bool = Query(True, description="Whether to postprocess results with LLM to synthesize an answer (default: true)"),
deduplicate: bool = Query(False, description="Whether to deduplicate results by title_proc (default: false)")
):
"""
Search the TAIS knowledge base using semantic similarity with product-based filtering.
This endpoint:
1. Preprocesses the query with OCI GenAI (if preprocess=true)
2. Generates embeddings for the query using OCI GenAI
3. Performs vector similarity search against Oracle ADB
4. Optionally deduplicates results by title_proc (if deduplicate=true)
5. Postprocesses results with LLM to synthesize an answer (if postprocess=true)
6. Returns the most relevant documents with their chunks and distances
Args:
query: Search query text (e.g., "como cancelar contrato")
product: Product to search within (default: 'MOVEL', options: 'MOVEL', 'FIBRA')
segments: List of segments to filter by (e.g., 'pospago', 'controle'); optional
sub_segments: List of sub-segments to filter by; optional
top_k: Number of results to return (default: 5, max: 100)
check_expiration_date: Whether to exclude expired documents (default: true)
preprocess: Whether to preprocess the query with OCI GenAI before searching (default: true)
postprocess: Whether to postprocess results with LLM to synthesize an answer (default: true)
deduplicate: Whether to deduplicate results by title_proc (default: false)
Returns:
TaisKbSearchResponse with:
- results: List of matching documents sorted by relevance
- total_results: Count of results returned
- query: Original query text
- reformulated_query: Query after preprocessing (if preprocess=true)
- postprocessing: Synthesized answer from LLM (if postprocess=true)
Example:
```bash
curl "http://localhost:8000/agent/search-tais-kb?query=cancelar&product=MOVEL&segments=pospago&segments=controle&sub_segments=fatura&sub_segments=express&top_k=5&check_expiration_date=true&preprocess=true&deduplicate=false"
```
Raises:
HTTPException 400: If product is invalid
HTTPException 500: If the search fails or configuration is missing
"""
correlation_id = str(uuid.uuid4())
logger.info(
"TAIS KB search initiated",
extra={
"correlation_id": correlation_id,
"query_length": len(query),
"query": query,
"top_k": top_k,
"segments": segments,
"sub_segments": sub_segments,
"product": product,
"check_expiration_date": check_expiration_date,
"preprocess": preprocess,
"postprocess": postprocess,
"deduplicate": deduplicate
}
)
try:
# Initialize TAIS KB client
tais_client = TaisKbClient()
# Convert product string to enum (accept both name and value)
try:
# Try by name first (e.g., "MOVEL")
product_enum = Product[product.upper()]
except KeyError:
try:
# Try by value (e.g., "Móvel")
product_enum = Product(product)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid product: {product}. Must be one of: MOVEL/FIBRA (name) or Móvel/Fibra (value)"
)
# Perform search with new API
search_response = await tais_client.search_documents(
query_text=query,
product=product_enum,
segments=segments,
sub_segments=sub_segments,
top_k=top_k,
check_expiration_date=check_expiration_date,
preprocess=preprocess,
postprocess=postprocess,
deduplicate=deduplicate
)
# Extract results and postprocessing from search response
reformulated_query = search_response.get("reformulated_query")
postprocessing_content = search_response.get("postprocessing_content")
postprocessing_id_procs = search_response.get("postprocessing_id_procs")
postprocessing_id_procs_map = search_response.get("postprocessing_id_procs_map")
postprocessing_prompt = search_response.get("postprocessing_prompt")
# Transform results to match response schema
# Include: id_proc, title_proc, description_proc, content, segment, sub_segments, distance
results = [
TaisKbSearchResultItem(
id_proc=r.get("id_proc") or "",
title_proc=r.get("title_proc") or "",
description_proc=r.get("description_proc") or "",
content=r.get("content") or "",
segment=r.get("segment") or "",
sub_segments=r.get("sub_segments") or "",
distance=float(r.get("distance", 0.0))
)
for r in search_response["results"]
]
logger.info(
"TAIS KB search completed",
extra={
"correlation_id": correlation_id,
"results_count": len(results),
"query": query,
"reformulated_query": reformulated_query,
"product": product,
"preprocess": preprocess,
"postprocess": postprocess,
"deduplicate": deduplicate,
"postprocessing_provided": bool(postprocessing_content)
}
)
return TaisKbSearchResponse(
results=results,
total_results=len(results),
query=query,
reformulated_query=reformulated_query,
postprocessing_content=postprocessing_content,
postprocessing_id_procs=postprocessing_id_procs,
postprocessing_id_procs_map=postprocessing_id_procs_map,
postprocessing_prompt=postprocessing_prompt,
sql=search_response["sql"]
)
except HTTPException:
raise
except Exception as e:
logger.exception(
"TAIS KB search failed",
extra={
"correlation_id": correlation_id,
"query": query,
"product": product,
"segments": segments,
"sub_segments": sub_segments,
"error": str(e)
}
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"TAIS KB search failed: {str(e)}"
)

View File

@@ -0,0 +1,511 @@
"""REST endpoints for the response emulator.
Three endpoints, four operator actions:
POST /case/{transaction_id}/response-emulator
action="generate" → first draft (requires `selected_actions`).
Rejected (409 DRAFT_ALREADY_EXISTS) when a
draft is already persisted.
PATCH /case/{transaction_id}/response-emulator
action="regenerate" → re-runs generation using the operator's feedback
(requires `operator_instructions`).
Rejected (409 NO_DRAFT_TO_REGENERATE) when
there is no draft to feed back from.
action="approve" → marks the draft as approved
(processing.status="approved"). No OCI publish,
no Siebel SR close. Rejected with
409 NO_DRAFT_TO_APPROVE / ALREADY_APPROVED /
CASE_ALREADY_CLOSED.
action="close" → publishes TicketResponseEvent on OCI and closes
the Siebel SR. Requires status=="approved";
rejected with 409 NOT_APPROVED_YET /
CASE_ALREADY_CLOSED otherwise.
GET /case/{transaction_id}/response-emulator
Read-only status snapshot. Does not invoke the graph. Returns the
full transition history.
"""
import time
from typing import Optional
from fastapi import APIRouter, HTTPException, Request, status
from agent_framework.observer import event as _noc_event
from src.api.dependencies.logging_context import set_emulator_log_context
from src.api.executors.response_emulator import process_response_emulator
from src.api.schemas.anatel_response_emulator_schemas import (
EmulatorFinalizeRequest,
EmulatorGenerateRequest,
EmulatorStatusResponse,
OperatorFeedback,
ResponseEmulatorRequestEvent,
Transition,
)
from src.api.utils.agent_helpers import create_error_response
from src.api.utils.emulator_response_builder import build_emulator_response_event
from src.core.config import settings
from src.core.logging import get_logger
from src.infrastructure.oci.autonomous.connection import db_manager
from src.utils.ics_collector import (
ICsCollector,
build_noc_latency_metadata,
build_noc_metadata,
)
logger = get_logger(__name__)
router = APIRouter()
_NOC_EMULATOR_FAILURE = "NOC.005"
_NOC_EMULATOR_COMPLETED = "NOC.006"
# --- Shared helpers ---
def _validate_path_body_match(path_transaction_id: str, body_transaction_id: str) -> None:
if path_transaction_id != body_transaction_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"transactionId mismatch: path={path_transaction_id} "
f"vs body={body_transaction_id}"
),
)
def _require_db() -> None:
if db_manager.db is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Autonomous DB unavailable",
)
async def _load_case_or_404(transaction_id: str) -> dict:
_require_db()
case_data = await db_manager.get_data(
"transactionId",
transaction_id,
collection=settings.AUTONOMOUS_NOSQL_COLLECTION,
)
if not case_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Case not found for transactionId={transaction_id}",
)
return case_data
def _conflict(code: str, message: str, current_status: Optional[str]) -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": code, "detail": message, "status": current_status},
)
def _emit_failure_event(transaction_id: str, request_payload: dict, message: str) -> None:
noc_state = {
"session_id": transaction_id,
"metadata": {"request_context": request_payload},
}
_noc_event(
_NOC_EMULATOR_FAILURE,
{
"status": message,
"type": "FAILURE",
**build_noc_metadata(noc_state, _NOC_EMULATOR_FAILURE),
},
metadata={"noc": True},
)
def _emit_completion_event(final_state, start_time: float) -> None:
_noc_event(
_NOC_EMULATOR_COMPLETED,
{
"status": "Emulator flow completed — sending response",
"type": "INFO",
**build_noc_latency_metadata(
final_state,
_NOC_EMULATOR_COMPLETED,
latency_ms=int((time.time() - start_time) * 1000),
),
},
metadata={"noc": True},
)
# --- Graph runner (shared by generate + finalize) ---
async def _run_emulator(event: ResponseEmulatorRequestEvent, fastapi_request: Request):
"""Runs the emulator graph and returns the standard REST payload."""
transaction_id = event.transactionId
request_payload = event.model_dump()
ICsCollector.start(transaction_id)
start_time = time.time()
try:
final_state = await process_response_emulator(event, fastapi_request.app.state)
except Exception as exc:
logger.exception(
"Emulator run failed",
extra={"correlation_id": transaction_id, "error": str(exc)},
)
_emit_failure_event(
transaction_id,
request_payload,
f"Fatal emulator exception: [{type(exc).__name__}] {exc}",
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Emulator run failed: {exc}",
) from exc
if final_state is None:
_emit_failure_event(
transaction_id,
request_payload,
"Emulator run failed in executor",
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Emulator run failed",
)
response_event = build_emulator_response_event(final_state, transaction_id)
error_info = final_state.get("error")
_emit_completion_event(final_state, start_time)
if error_info:
return _build_error_payload(error_info, final_state, response_event, transaction_id)
logger.info(
"Emulator run completed",
extra={
"correlation_id": transaction_id,
"flow_mode": event.flow_mode,
"emulation_type": event.type,
"current_step": response_event.processing.current_step,
"status": response_event.processing.status,
},
)
return {
"message": "Emulator run completed",
"correlation_id": transaction_id,
"flow_mode": event.flow_mode,
"emulation_type": event.type,
"status": response_event.processing.status,
"current_step": response_event.processing.current_step,
"case_response": response_event.processing.case_response,
"metadata": response_event.processing.metadata,
}
def _build_error_payload(error_info: dict, final_state, response_event, transaction_id: str):
if error_info.get("type") == "MissingRequiredFieldsError":
missing = (final_state.get("metadata") or {}).get("missing_required_fields") or []
return create_error_response(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
correlation_id=transaction_id,
final_response=error_info.get("message", "Required fields not filled"),
parsed_response={
"title": "missing required fields",
"status": status.HTTP_422_UNPROCESSABLE_ENTITY,
"detail": {
"messages": [
{
"code": "MISSING_REQUIRED_FIELD",
"text": m.get("reason") or "required field missing",
"path": m.get("path"),
"field_name": m.get("field_name"),
"kind": m.get("kind"),
}
for m in missing
],
},
},
metadata=response_event.processing.metadata,
)
return create_error_response(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
correlation_id=transaction_id,
final_response=error_info.get("message", "emulator failed"),
parsed_response=None,
metadata=response_event.processing.metadata,
)
# --- Action → event builders ---
def _event_generate(transaction_id: str, body: EmulatorGenerateRequest) -> ResponseEmulatorRequestEvent:
return ResponseEmulatorRequestEvent(
transactionId=transaction_id,
type="first_response",
flow_mode="generate",
selected_actions=body.selected_actions or [],
)
def _event_regenerate(
transaction_id: str,
body: EmulatorGenerateRequest,
case_data: dict,
) -> ResponseEmulatorRequestEvent:
processing = case_data.get("processing") or {}
metadata = case_data.get("metadata") or {}
previous_response = processing.get("case_response")
stored_actions = metadata.get("selected_actions") or []
if not stored_actions:
raise _conflict(
"NO_DRAFT_TO_REGENERATE",
"metadata.selected_actions missing — generate a draft first.",
processing.get("status"),
)
return ResponseEmulatorRequestEvent(
transactionId=transaction_id,
type="regenerate",
flow_mode="generate",
selected_actions=stored_actions,
previous_response=previous_response,
feedback=OperatorFeedback(comment=body.operator_instructions or ""),
)
# --- POST /generate ---
@router.post(
"/{transaction_id}/response-emulator/generate",
status_code=status.HTTP_200_OK,
summary="Gera ou regera o rascunho de resposta",
description=(
"Discriminado por `action`:\n\n"
"- `generate`: primeira geração. Exige `selected_actions`. Retorna 409 "
"`DRAFT_ALREADY_EXISTS` se já houver rascunho persistido — neste "
"caso o cliente deve chamar com `action='regenerate'`.\n"
"- `regenerate`: reexecuta usando `operator_instructions` como feedback. "
"`selected_actions` e `previous_response` são lidos do DB. Retorna 409 "
"`NO_DRAFT_TO_REGENERATE` se ainda não houver rascunho."
),
)
async def generate_case_response(
transaction_id: str,
body: EmulatorGenerateRequest,
fastapi_request: Request,
):
set_emulator_log_context(transaction_id)
_validate_path_body_match(transaction_id, body.transactionId)
case_data = await _load_case_or_404(transaction_id)
processing = case_data.get("processing") or {}
current_status = processing.get("status")
has_draft = bool(processing.get("case_response"))
if body.action == "generate":
if has_draft:
raise _conflict(
"DRAFT_ALREADY_EXISTS",
"A draft response is already persisted — use action='regenerate'.",
current_status,
)
event = _event_generate(transaction_id, body)
else: # regenerate
if not has_draft:
raise _conflict(
"NO_DRAFT_TO_REGENERATE",
"No previous draft in processing.case_response — call action='generate' first.",
current_status,
)
event = _event_regenerate(transaction_id, body, case_data)
logger.info(
"Emulator generate started",
extra={
"correlation_id": transaction_id,
"action": body.action,
"selected_actions_count": len(event.selected_actions),
},
)
return await _run_emulator(event, fastapi_request)
# --- POST /finalize ---
def _event_approve(transaction_id: str) -> ResponseEmulatorRequestEvent:
return ResponseEmulatorRequestEvent(
transactionId=transaction_id,
type="first_response",
flow_mode="approve",
selected_actions=[],
)
def _event_close(transaction_id: str) -> ResponseEmulatorRequestEvent:
return ResponseEmulatorRequestEvent(
transactionId=transaction_id,
type="first_response",
flow_mode="close",
selected_actions=[],
)
def _check_approve_preconditions(processing: dict) -> None:
current_status = processing.get("status")
if not processing.get("case_response"):
raise _conflict(
"NO_DRAFT_TO_APPROVE",
"No draft in processing.case_response — generate one first.",
current_status,
)
if current_status == "done":
raise _conflict(
"CASE_ALREADY_CLOSED",
"Case is already closed.",
current_status,
)
if current_status == "approved":
raise _conflict(
"ALREADY_APPROVED",
"Draft is already approved — call action='close' to finalize.",
current_status,
)
def _check_close_preconditions(processing: dict, case_data: dict) -> None:
current_status = processing.get("status")
if current_status == "done":
raise _conflict(
"CASE_ALREADY_CLOSED",
"Case is already closed.",
current_status,
)
if current_status not in ("approved", "siebel_closing_failed"):
raise _conflict(
"NOT_APPROVED_YET",
"Draft must be approved before closing — call action='approve' first.",
current_status,
)
# Defense-in-depth: status='approved' should imply a case_response is
# persisted (approve precondition checks it). If it isn't here, the
# CMS callback for the prior approve event hasn't been applied yet.
if not processing.get("case_response"):
raise _conflict(
"DRAFT_NOT_PERSISTED",
"Status is 'approved' but processing.case_response is empty — "
"the prior approve event has likely not been persisted by the "
"CMS yet. Retry in a moment.",
current_status,
)
# close_case_node needs a Siebel SR protocol from the persisted doc.
# Root `crmProtocol` is intentionally ignored — see
# `close_case_node._resolve_sr_protocol` docstring for why the
# simulator's placeholder value there is not trusted.
sr_data = case_data.get("siebel_sr_data") or {}
has_sr = bool(
processing.get("crmProtocol")
or sr_data.get("interactionProtocol")
)
if not has_sr:
raise _conflict(
"MISSING_CRM_PROTOCOL",
"No Siebel SR protocol on the case (processing.crmProtocol / "
"siebel_sr_data.interactionProtocol empty). The treatment SR "
"must be opened before closing the case.",
current_status,
)
@router.post(
"/{transaction_id}/response-emulator/finalize",
status_code=status.HTTP_200_OK,
summary="Aprova ou fecha o chamado",
description=(
"Discriminado por `action`:\n\n"
"- `approve`: registra a aprovação do operador "
"(`processing.status='approved'`). Não publica em OCI e não fecha o "
"SR no Siebel. Retorna 409 `NO_DRAFT_TO_APPROVE`, `ALREADY_APPROVED` "
"ou `CASE_ALREADY_CLOSED`.\n"
"- `close`: publica `TicketResponseEvent` na OCI e fecha o SR no Siebel. "
"Exige `processing.status='approved'`; caso contrário retorna 409 "
"`NOT_APPROVED_YET` ou `CASE_ALREADY_CLOSED`."
),
)
async def finalize_case_response(
transaction_id: str,
body: EmulatorFinalizeRequest,
fastapi_request: Request,
):
set_emulator_log_context(transaction_id)
_validate_path_body_match(transaction_id, body.transactionId)
case_data = await _load_case_or_404(transaction_id)
processing = case_data.get("processing") or {}
if body.action == "approve":
_check_approve_preconditions(processing)
event = _event_approve(transaction_id)
else: # close
_check_close_preconditions(processing, case_data)
event = _event_close(transaction_id)
logger.info(
"Emulator finalize started",
extra={
"correlation_id": transaction_id,
"action": body.action,
"previous_status": processing.get("status"),
},
)
return await _run_emulator(event, fastapi_request)
# --- GET /status ---
@router.get(
"/{transaction_id}/response-emulator",
status_code=status.HTTP_200_OK,
summary="Status atual do caso no emulador",
description=(
"Snapshot read-only de `processing.status`, candidato vigente, "
"validação estrutural e histórico de transições. Não invoca o graph."
),
)
async def get_emulator_status(transaction_id: str) -> EmulatorStatusResponse:
set_emulator_log_context(transaction_id)
case_data = await _load_case_or_404(transaction_id)
processing = case_data.get("processing") or {}
metadata = case_data.get("metadata") or {}
raw_transitions = processing.get("transitions") or []
transitions = [Transition(**t) for t in raw_transitions]
regenerate_count = sum(1 for t in transitions if t.event == "regenerated")
last_updated_at = transitions[-1].at if transitions else None
return EmulatorStatusResponse(
transactionId=transaction_id,
status=processing.get("status"),
current_step=processing.get("current_step"),
case_response=processing.get("case_response"),
validation=metadata.get("validation"),
selected_actions_count=len(metadata.get("selected_actions") or []),
regenerate_count=regenerate_count,
transitions=transitions,
last_updated_at=last_updated_at,
)

View File

@@ -0,0 +1,101 @@
"""
Endpoint isolado para testar a recuperação dos RAGs do response emulator.
Recebe um texto de consulta, gera o embedding via OCI GenAI (Cohere) e roda
busca vetorial (VECTOR_DISTANCE/COSINE) direto nas tabelas COHERE_3 do ADB,
retornando os chunks mais próximos — sem passar pelo grafo do agente. É uma
ferramenta de QA para validar a qualidade da recuperação.
"""
import uuid
from fastapi import APIRouter, HTTPException, Query, status
from src.api.schemas.emulator_rag_schemas import (
EmulatorRagSearchResponse,
EmulatorRagSearchResultItem,
EmulatorRagSourceEnum,
)
from src.components.clients.emulator_rag_client import EmulatorRagClient, EmulatorRagSource
from src.components.clients.exceptions.emulator_rag_exceptions import EmulatorRagClientError
from src.core.config import settings
from src.core.logging import get_logger
logger = get_logger(__name__)
router = APIRouter()
@router.get(
"/search",
response_model=EmulatorRagSearchResponse,
status_code=status.HTTP_200_OK,
summary="Busca vetorial de teste nos RAGs do emulator",
description=(
"Gera o embedding do `query` e retorna os chunks mais próximos da fonte "
"escolhida (`templates`, `anatel_resposta`), ordenados por distância COSINE "
"crescente. Endpoint de QA, não publica nada de volta."
),
)
async def search_emulator_rag(
query: str = Query(..., min_length=1, description="Texto de consulta a ser embeddado e buscado"),
source: EmulatorRagSourceEnum = Query(..., description="Fonte RAG a consultar"),
top_k: int | None = Query(None, ge=1, le=50, description="Qtd de chunks a retornar (default via settings)"),
nota: int = Query(4, ge=0, le=5, description="Filtro `nota >= nota` (efetivo só em anatel_resposta)"),
) -> EmulatorRagSearchResponse:
correlation_id = str(uuid.uuid4())
effective_top_k = top_k or settings.EMULATOR_RAG_TOP_K
logger.info(
"emulator_rag search initiated | correlation_id=%s | source=%s | top_k=%d | nota>=%d | query_len=%d",
correlation_id,
source.value,
effective_top_k,
nota,
len(query),
)
try:
client = EmulatorRagClient()
search_response = await client.search(
source=EmulatorRagSource(source.value),
query=query,
nota_min=nota,
top_k=effective_top_k,
)
results = [
EmulatorRagSearchResultItem(
id=r.get("id") or "",
content=r.get("content") or "",
distance=float(r.get("distance", 0.0)),
metadata=r.get("metadata"),
)
for r in search_response["results"]
]
logger.info(
"emulator_rag search completed | correlation_id=%s | results=%d",
correlation_id,
len(results),
)
return EmulatorRagSearchResponse(
results=results,
total_results=len(results),
source=source,
query=query,
top_k=search_response["top_k"],
sql=search_response["sql"],
)
except ValueError as exc:
logger.warning("emulator_rag bad request | correlation_id=%s | error=%s", correlation_id, exc)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except EmulatorRagClientError as exc:
logger.error("emulator_rag client error | correlation_id=%s | error=%s", correlation_id, exc)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
except Exception as exc:
logger.exception("emulator_rag search failed | correlation_id=%s", correlation_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"emulator RAG search failed: {exc}",
) from exc

View File

@@ -0,0 +1,136 @@
"""
Health check routes.
This module defines health check endpoints for monitoring and orchestration.
"""
from fastapi import APIRouter, status
from src.api.schemas import HealthResponse
from src.core.config import settings
from src.core.logging import get_logger
logger = get_logger(__name__)
router = APIRouter()
@router.get(
"/live",
response_model=HealthResponse,
status_code=status.HTTP_200_OK,
summary="Liveness probe",
description="Check if the service is alive and running"
)
async def liveness() -> HealthResponse:
"""
Liveness probe endpoint.
This endpoint indicates whether the service is running.
It should return 200 OK if the service is alive, regardless of
whether it can handle requests.
Used by:
- Kubernetes liveness probes
- Docker health checks
- Load balancers
Returns:
Health response with status "alive"
Example:
```bash
curl http://localhost:8000/health/live
```
Response:
```json
{
"status": "alive",
"version": "1.0.0",
"timestamp": "2024-01-15T10:30:00Z"
}
```
"""
logger.debug("Liveness check")
return HealthResponse(
status="alive",
version=settings.VERSION
)
@router.get(
"/ready",
response_model=HealthResponse,
status_code=status.HTTP_200_OK,
summary="Readiness probe",
description="Check if the service is ready to handle requests"
)
async def readiness() -> HealthResponse:
"""
Readiness probe endpoint.
This endpoint indicates whether the service is ready to handle requests.
It should return 200 OK only if the service can successfully process
requests (e.g., dependencies are available, initialization is complete).
Used by:
- Kubernetes readiness probes
- Load balancers to determine if traffic should be routed
- Service mesh health checks
Current implementation:
- Returns "ready" if basic checks pass
- In production, you might want to check:
* Database connectivity
* External API availability
* LLM provider status
* Memory/resource availability
Returns:
Health response with status "ready"
Example:
```bash
curl http://localhost:8000/health/ready
```
Response:
```json
{
"status": "ready",
"version": "1.0.0",
"timestamp": "2024-01-15T10:30:00Z"
}
```
"""
logger.debug("Readiness check")
# TODO: Add actual readiness checks here
# Examples:
# - Check if LLM provider is accessible
# - Check if required environment variables are set
# - Check if memory usage is within limits
# - Check if any critical dependencies are available
try:
# Basic check: verify configuration is loaded
_ = settings.APP_NAME
_ = settings.VERSION
# If we get here, basic checks passed
return HealthResponse(
status="ready",
version=settings.VERSION
)
except Exception as e:
logger.error(
"Readiness check failed",
extra={"error": str(e), "error_type": type(e).__name__}
)
# Return unhealthy status
return HealthResponse(
status="unhealthy",
version=settings.VERSION
)