mirror of
https://github.com/hoshikawa2/compass_backoffice.git
synced 2026-07-09 13:54:20 +00:00
first commit
This commit is contained in:
BIN
legacy_reference_disabled/original_develop/.DS_Store
vendored
Normal file
BIN
legacy_reference_disabled/original_develop/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
454
legacy_reference_disabled/original_develop/src_api_main.py
Normal file
454
legacy_reference_disabled/original_develop/src_api_main.py
Normal 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()
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
API route modules.
|
||||
"""
|
||||
|
||||
from src.api.routes import agent, health
|
||||
|
||||
__all__ = [
|
||||
"agent",
|
||||
"health",
|
||||
]
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user