From 89c23fb0ed3a862e4a6ef03211f8fd1c5ad23a65 Mon Sep 17 00:00:00 2001 From: Cristiano Hoshikawa Date: Sat, 13 Jun 2026 08:23:21 -0300 Subject: [PATCH] first commit --- .env | 239 +++++ .env.backoffice_mcp.example | 29 + .env.example | 98 ++ .idea/.gitignore | 12 + .idea/backoffice_convertido_framework.iml | 9 + .idea/codeStyles/Project.xml | 7 + .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .oca/custom_code_review_guidelines.txt | 24 + Dockerfile | 6 + README_BACKOFFICE_CONVERTIDO_100.md | 156 +++ README_BACKOFFICE_FRAMEWORK_NATIVE_100.md | 77 ++ README_BACKOFFICE_NATIVE_FRAMEWORK.md | 64 ++ README_ENTERPRISE_TEMPLATE.md | 54 + README_MIGRACAO_BACKOFFICE.md | 184 ++++ app/.DS_Store | Bin 0 -> 10244 bytes app/__init__.py | 0 app/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 184 bytes .../identity_extraction.cpython-313.pyc | Bin 0 -> 4350 bytes app/__pycache__/main.cpython-313.pyc | Bin 0 -> 47832 bytes app/__pycache__/state.cpython-313.pyc | Bin 0 -> 1715 bytes app/agents/README.md | 15 + .../backoffice_agent.cpython-313.pyc | Bin 0 -> 20700 bytes .../__pycache__/billing_agent.cpython-313.pyc | Bin 0 -> 4042 bytes .../__pycache__/orders_agent.cpython-313.pyc | Bin 0 -> 3940 bytes .../__pycache__/product_agent.cpython-313.pyc | Bin 0 -> 4066 bytes .../__pycache__/prompting.cpython-313.pyc | Bin 0 -> 1181 bytes .../__pycache__/runtime.cpython-313.pyc | Bin 0 -> 15323 bytes .../__pycache__/support_agent.cpython-313.pyc | Bin 0 -> 3777 bytes app/agents/backoffice_agent.py | 421 ++++++++ app/agents/billing_agent.py | 70 ++ app/agents/orders_agent.py | 69 ++ app/agents/product_agent.py | 70 ++ app/agents/prompting.py | 15 + app/agents/runtime.py | 267 +++++ app/agents/support_agent.py | 67 ++ app/domain/.DS_Store | Bin 0 -> 6148 bytes app/domain/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 137 bytes app/domain/backoffice/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 148 bytes app/examples/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 201 bytes .../__pycache__/grl_examples.cpython-313.pyc | Bin 0 -> 1783 bytes .../__pycache__/ic_examples.cpython-313.pyc | Bin 0 -> 1739 bytes .../__pycache__/mcp_examples.cpython-313.pyc | Bin 0 -> 1881 bytes .../__pycache__/noc_examples.cpython-313.pyc | Bin 0 -> 1828 bytes .../observer_examples.cpython-313.pyc | Bin 0 -> 1337 bytes app/examples/grl_examples.py | 37 + app/examples/ic_examples.py | 34 + app/examples/mcp_examples.py | 43 + app/examples/noc_examples.py | 37 + app/examples/observer_examples.py | 28 + app/identity_extraction.py | 90 ++ app/main.py | 826 +++++++++++++++ app/state.py | 34 + .../__pycache__/agent_graph.cpython-313.pyc | Bin 0 -> 46274 bytes .../backoffice_native_runtime.cpython-313.pyc | Bin 0 -> 59884 bytes app/workflows/agent_graph.py | 718 +++++++++++++ app/workflows/backoffice_native_runtime.py | 748 ++++++++++++++ config/.DS_Store | Bin 0 -> 6148 bytes config/agents.yaml | 18 + config/agents/.DS_Store | Bin 0 -> 6148 bytes .../agents/backoffice_anatel/guardrails.yaml | 54 + config/agents/backoffice_anatel/judges.yaml | 20 + .../backoffice_anatel/prompt_policy.yaml | 25 + config/agents/retail_orders/guardrails.yaml | 8 + config/agents/retail_orders/judges.yaml | 7 + .../agents/retail_orders/prompt_policy.yaml | 6 + config/agents/telecom_contas/guardrails.yaml | 8 + config/agents/telecom_contas/judges.yaml | 7 + .../agents/telecom_contas/prompt_policy.yaml | 6 + config/guardrails.yaml | 8 + config/identity.yaml | 79 ++ config/judges.yaml | 7 + config/mcp_parameter_mapping.yaml | 51 + config/mcp_servers.docker.yaml | 6 + config/mcp_servers.yaml | 7 + config/prompt_policy.yaml | 19 + config/routing.yaml | 120 +++ config/tools.yaml | 62 ++ configs_original_develop/README.md | 4 + configs_original_develop/config.example.yaml | 32 + curl_test.txt | 53 + ddl/README.md | 3 + ddl/anatel_notas.sql | 108 ++ ddl/create-collections.sql | 14 + ddl/manual_anatel.sql | 83 ++ ddl/templates.sql | 30 + ddl_original_develop/README.md | 3 + ddl_original_develop/anatel_notas.sql | 108 ++ ddl_original_develop/create-collections.sql | 14 + ddl_original_develop/manual_anatel.sql | 83 ++ ddl_original_develop/templates.sql | 30 + docker-compose.yml | 28 + docs/AJUSTES_IC_NOC_GRL_OCI_OPENAI.md | 39 + ...AO_TEMPLATE_ANALYTICS_OUTPUT_SUPERVISOR.md | 95 ++ docs/COMO_USAR_IC_NOC_GRL_NO_TEMPLATE.md | 45 + docs/CORRECAO_ENV_MCP_TIM_CLIENTS.md | 88 ++ docs/CORRECAO_EXTRACAO_CPF_TEXTO_LIVRE.md | 57 ++ docs/CORRECAO_IMPORTS_AGENT_FRAMEWORK_7.md | 55 + docs/CORRECAO_MCP_PARAMETROS_CPF_EXPLICITO.md | 8 + docs/CORRECAO_PROTOCOL_ID_MCP_MAPPING.md | 81 ++ docs/GUARDRAILS_PARALLELOS_OBSERVER_IC.md | 127 +++ ...EMENTACAO_IC_NOC_GRL_SEM_REMOVER_LOGICA.md | 42 + docs/OPCAO_B_LLM_PROVIDER_TELEMETRY.md | 32 + docs/RELATORIO_FRAMEWORK_NATIVE_100.md | 110 ++ docs/RELATORIO_PARIDADE_BACKOFFICE_100.md | 45 + docs/VALIDACAO_BACKEND_IC_NOC_GRL.md | 62 ++ docs/VALIDACAO_TEMPLATE_ENTERPRISE.txt | 3 + docs_original_develop/README.md | 3 + docs_original_develop/api-clients.md | 137 +++ docs_original_develop/ics.md | 119 +++ docs_original_develop/logging.md | 304 ++++++ legacy_reference_disabled/.DS_Store | Bin 0 -> 6148 bytes .../original_develop/.DS_Store | Bin 0 -> 6148 bytes .../src_agent_graphs/__init__.py | 24 + .../src_agent_graphs/emulator_graph.py | 162 +++ .../src_agent_graphs/llm_testing_graph.py | 88 ++ .../src_agent_graphs/main_graph.py | 279 +++++ .../src_api_executors/__init__.py | 21 + .../src_api_executors/checklist.py | 253 +++++ .../src_api_executors/response_emulator.py | 262 +++++ .../original_develop/src_api_main.py | 454 +++++++++ .../src_api_routes/__init__.py | 10 + .../original_develop/src_api_routes/agent.py | 950 ++++++++++++++++++ .../src_api_routes/emulator.py | 511 ++++++++++ .../src_api_routes/emulator_rag.py | 101 ++ .../original_develop/src_api_routes/health.py | 136 +++ mcp_servers/.DS_Store | Bin 0 -> 10244 bytes mcp_servers/backoffice_mcp_server/README.md | 54 + mcp_servers/backoffice_mcp_server/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 289 bytes .../__pycache__/clients.cpython-313.pyc | Bin 0 -> 3288 bytes .../__pycache__/main.cpython-313.pyc | Bin 0 -> 7589 bytes .../__pycache__/models.cpython-313.pyc | Bin 0 -> 1955 bytes .../__pycache__/settings.cpython-313.pyc | Bin 0 -> 5824 bytes .../__pycache__/store.cpython-313.pyc | Bin 0 -> 19084 bytes mcp_servers/backoffice_mcp_server/clients.py | 37 + mcp_servers/backoffice_mcp_server/main.py | 176 ++++ mcp_servers/backoffice_mcp_server/models.py | 35 + mcp_servers/backoffice_mcp_server/settings.py | 81 ++ mcp_servers/backoffice_mcp_server/store.py | 343 +++++++ pyproject.toml | 32 + requirements.txt | 30 + scripts/run_backoffice_mcp.sh | 22 + scripts_original_develop/.gitkeep | 1 + .../get_prompt_from_langfuse.py | 72 ++ .../upload_prompts_to_langfuse.py | 85 ++ sh_original_develop/run_export.sh | 13 + src/.DS_Store | Bin 0 -> 8196 bytes src/__init__.py | 3 + src/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 309 bytes src/__pycache__/__init__.cpython-39.pyc | Bin 0 -> 302 bytes src/agent/.DS_Store | Bin 0 -> 6148 bytes src/agent/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 256 bytes src/agent/__pycache__/__init__.cpython-39.pyc | Bin 0 -> 252 bytes src/agent/graphs_README_DISABLED.md | 5 + .../canceling_analysis.cpython-313.pyc | Bin 0 -> 2037 bytes .../duplicate_analysis.cpython-313.pyc | Bin 0 -> 6923 bytes .../postprocess_tais_kb_query.cpython-313.pyc | Bin 0 -> 4263 bytes .../preprocess_tais_kb_query.cpython-313.pyc | Bin 0 -> 2920 bytes .../speech_history_analysis.cpython-313.pyc | Bin 0 -> 1854 bytes .../ticket_reclassification.cpython-313.pyc | Bin 0 -> 2141 bytes .../tim_complaint_analysis.cpython-313.pyc | Bin 0 -> 4655 bytes .../treatment_decision_prompt.cpython-313.pyc | Bin 0 -> 4502 bytes .../anatel_motives.cpython-313.pyc | Bin 0 -> 16628 bytes .../anatel_motives/anatel_motives.py | 310 ++++++ src/agent/local_prompts/canceling_analysis.py | 48 + src/agent/local_prompts/duplicate_analysis.py | 124 +++ src/agent/local_prompts/emulator/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 213 bytes ...sponse_emulator_generation.cpython-313.pyc | Bin 0 -> 10955 bytes .../emulator/response_emulator_generation.py | 223 ++++ .../postprocess_tais_kb_query.py | 113 +++ .../local_prompts/preprocess_tais_kb_query.py | 84 ++ .../local_prompts/speech_history_analysis.py | 47 + .../local_prompts/ticket_reclassification.py | 44 + .../local_prompts/tim_complaint_analysis.py | 49 + .../treatment_decision_prompt.py | 58 ++ .../__pycache__/validation.cpython-313.pyc | Bin 0 -> 10319 bytes src/agent/logic/validation.py | 226 +++++ src/agent/nodes/__init__.py | 40 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 914 bytes .../nodes/__pycache__/__init__.cpython-39.pyc | Bin 0 -> 883 bytes .../bypass_rules_node.cpython-313.pyc | Bin 0 -> 6236 bytes .../cache_check_node.cpython-313.pyc | Bin 0 -> 4826 bytes .../canceling_analysis_node.cpython-313.pyc | Bin 0 -> 17639 bytes ...nt_complaint_operator_node.cpython-313.pyc | Bin 0 -> 10047 bytes ...ent_complaint_operator_node.cpython-39.pyc | Bin 0 -> 6214 bytes .../fetch_ticket_node.cpython-313.pyc | Bin 0 -> 2594 bytes ...identity_verification_node.cpython-313.pyc | Bin 0 -> 8332 bytes .../imdb_enrichment_node.cpython-313.pyc | Bin 0 -> 8163 bytes ...ledge_base_enrichment_node.cpython-313.pyc | Bin 0 -> 15466 bytes ...assification_analysis_node.cpython-313.pyc | Bin 0 -> 11958 bytes .../siebel_sr_opening_node.cpython-313.pyc | Bin 0 -> 14440 bytes ...riplet_classification_node.cpython-313.pyc | Bin 0 -> 6236 bytes .../speech_enrichment_node.cpython-313.pyc | Bin 0 -> 7416 bytes ...im_complaint_analysis_node.cpython-313.pyc | Bin 0 -> 10388 bytes .../tim_complaint_node.cpython-313.pyc | Bin 0 -> 1977 bytes .../treatment_decision_node.cpython-313.pyc | Bin 0 -> 5072 bytes ...ed_complaint_operator_node.cpython-313.pyc | Bin 0 -> 16519 bytes .../validation_node.cpython-313.pyc | Bin 0 -> 10155 bytes src/agent/nodes/bypass_rules_node.py | 156 +++ src/agent/nodes/cache_check_node.py | 103 ++ src/agent/nodes/canceling_analysis_node.py | 416 ++++++++ .../different_complaint_operator_node.py | 277 +++++ src/agent/nodes/emulator/__init__.py | 27 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 656 bytes .../__pycache__/_rag_query.cpython-313.pyc | Bin 0 -> 3864 bytes .../approve_draft_node.cpython-313.pyc | Bin 0 -> 3185 bytes .../close_case_node.cpython-313.pyc | Bin 0 -> 13212 bytes .../fetch_case_node.cpython-313.pyc | Bin 0 -> 4966 bytes .../generate_response_node.cpython-313.pyc | Bin 0 -> 20773 bytes .../persist_draft_node.cpython-313.pyc | Bin 0 -> 4317 bytes .../retrieve_history_node.cpython-313.pyc | Bin 0 -> 3746 bytes .../retrieve_templates_node.cpython-313.pyc | Bin 0 -> 2233 bytes .../__pycache__/router_node.cpython-313.pyc | Bin 0 -> 2974 bytes ...rt_response_emulation_node.cpython-313.pyc | Bin 0 -> 5449 bytes .../validate_actions_node.cpython-313.pyc | Bin 0 -> 7238 bytes .../validate_response_node.cpython-313.pyc | Bin 0 -> 7685 bytes src/agent/nodes/emulator/_rag_query.py | 68 ++ .../nodes/emulator/approve_draft_node.py | 71 ++ src/agent/nodes/emulator/close_case_node.py | 290 ++++++ src/agent/nodes/emulator/fetch_case_node.py | 104 ++ .../nodes/emulator/generate_response_node.py | 461 +++++++++ .../nodes/emulator/persist_draft_node.py | 91 ++ .../nodes/emulator/retrieve_history_node.py | 68 ++ .../nodes/emulator/retrieve_templates_node.py | 38 + src/agent/nodes/emulator/router_node.py | 60 ++ .../emulator/start_response_emulation_node.py | 114 +++ .../nodes/emulator/validate_actions_node.py | 179 ++++ .../nodes/emulator/validate_response_node.py | 160 +++ src/agent/nodes/fetch_ticket_node.py | 58 ++ src/agent/nodes/identity_verification_node.py | 186 ++++ src/agent/nodes/imdb_enrichment_node.py | 189 ++++ .../nodes/knowledge_base_enrichment_node.py | 405 ++++++++ .../nodes/reclassification_analysis_node.py | 263 +++++ src/agent/nodes/siebel_sr_opening_node.py | 358 +++++++ .../siebel_triplet_classification_node.py | 133 +++ src/agent/nodes/speech_enrichment_node.py | 168 ++++ .../nodes/tim_complaint_analysis_node.py | 224 +++++ src/agent/nodes/tim_complaint_node.py | 37 + src/agent/nodes/treatment_decision_node.py | 147 +++ .../undefined_complaint_operator_node.py | 416 ++++++++ src/agent/nodes/validation_node.py | 198 ++++ src/agent/state/__init__.py | 9 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 415 bytes .../state/__pycache__/__init__.cpython-39.pyc | Bin 0 -> 401 bytes .../__pycache__/agent_state.cpython-313.pyc | Bin 0 -> 9896 bytes .../__pycache__/agent_state.cpython-39.pyc | Bin 0 -> 9232 bytes .../__pycache__/step_helpers.cpython-313.pyc | Bin 0 -> 4619 bytes .../step_helpers_emulator.cpython-313.pyc | Bin 0 -> 3638 bytes .../__pycache__/step_notes.cpython-313.pyc | Bin 0 -> 3361 bytes .../step_notes_emulator.cpython-313.pyc | Bin 0 -> 2726 bytes .../state/__pycache__/steps.cpython-313.pyc | Bin 0 -> 2980 bytes .../steps_emulator.cpython-313.pyc | Bin 0 -> 2020 bytes .../transitions_emulator.cpython-313.pyc | Bin 0 -> 2635 bytes src/agent/state/agent_state.py | 302 ++++++ src/agent/state/step_helpers.py | 89 ++ src/agent/state/step_helpers_emulator.py | 64 ++ src/agent/state/step_notes.py | 48 + src/agent/state/step_notes_emulator.py | 38 + src/agent/state/steps.py | 60 ++ src/agent/state/steps_emulator.py | 53 + src/agent/state/transitions_emulator.py | 49 + src/api/.DS_Store | Bin 0 -> 6148 bytes src/api/LEGACY_ROUTES_DISABLED.md | 5 + src/api/__init__.py | 1 + src/api/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 246 bytes src/api/dependencies/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 147 bytes .../logging_context.cpython-313.pyc | Bin 0 -> 3221 bytes src/api/dependencies/logging_context.py | 58 ++ src/api/middleware/__init__.py | 11 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 388 bytes .../__pycache__/error_handler.cpython-313.pyc | Bin 0 -> 5565 bytes .../__pycache__/logging.cpython-313.pyc | Bin 0 -> 2800 bytes src/api/middleware/error_handler.py | 151 +++ src/api/middleware/logging.py | 56 ++ src/api/schemas/__init__.py | 12 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 460 bytes .../__pycache__/abrt_schemas.cpython-313.pyc | Bin 0 -> 1518 bytes ..._response_emulator_schemas.cpython-313.pyc | Bin 0 -> 7730 bytes .../anatel_schemas.cpython-313.pyc | Bin 0 -> 15442 bytes .../emulator_rag_schemas.cpython-313.pyc | Bin 0 -> 1354 bytes .../__pycache__/imdb_schemas.cpython-313.pyc | Bin 0 -> 1427 bytes .../portability_schemas.cpython-313.pyc | Bin 0 -> 3117 bytes .../__pycache__/request.cpython-313.pyc | Bin 0 -> 2164 bytes .../__pycache__/response.cpython-313.pyc | Bin 0 -> 2464 bytes .../siebel_schemas.cpython-313.pyc | Bin 0 -> 11454 bytes .../tais_kb_schemas.cpython-313.pyc | Bin 0 -> 3848 bytes src/api/schemas/abrt_schemas.py | 16 + .../anatel_response_emulator_schemas.py | 165 +++ src/api/schemas/anatel_schemas.py | 313 ++++++ src/api/schemas/emulator_rag_schemas.py | 26 + src/api/schemas/imdb_schemas.py | 15 + src/api/schemas/portability_schemas.py | 30 + src/api/schemas/request.py | 53 + src/api/schemas/response.py | 77 ++ src/api/schemas/siebel_schemas.py | 230 +++++ src/api/schemas/tais_kb_schemas.py | 59 ++ .../__pycache__/agent_helpers.cpython-313.pyc | Bin 0 -> 9751 bytes .../emulator_response_builder.cpython-313.pyc | Bin 0 -> 9346 bytes src/api/utils/agent_helpers.py | 230 +++++ src/api/utils/emulator_response_builder.py | 202 ++++ src/compat/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 191 bytes .../framework_observer.cpython-313.pyc | Bin 0 -> 5887 bytes src/compat/framework_observer.py | 117 +++ src/components/.DS_Store | Bin 0 -> 6148 bytes src/components/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 256 bytes src/components/checkpointing/__init__.py | 7 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 347 bytes .../__pycache__/mongodb_saver.cpython-313.pyc | Bin 0 -> 10431 bytes src/components/checkpointing/mongodb_saver.py | 365 +++++++ .../__pycache__/abrt_client.cpython-313.pyc | Bin 0 -> 10905 bytes .../emulator_rag_client.cpython-313.pyc | Bin 0 -> 14219 bytes .../__pycache__/imdb_client.cpython-313.pyc | Bin 0 -> 10767 bytes .../portability_client.cpython-313.pyc | Bin 0 -> 11187 bytes .../__pycache__/siebel_client.cpython-313.pyc | Bin 0 -> 20117 bytes .../speech_analytics_client.cpython-313.pyc | Bin 0 -> 16122 bytes .../tais_kb_client.cpython-313.pyc | Bin 0 -> 35370 bytes src/components/clients/abrt_client.py | 233 +++++ src/components/clients/emulator_rag_client.py | 259 +++++ .../abrt_exceptions.cpython-313.pyc | Bin 0 -> 4689 bytes .../emulator_rag_exceptions.cpython-313.pyc | Bin 0 -> 1811 bytes .../imdb_exceptions.cpython-313.pyc | Bin 0 -> 3994 bytes .../portability_exceptions.cpython-313.pyc | Bin 0 -> 4815 bytes .../siebel_exceptions.cpython-313.pyc | Bin 0 -> 4635 bytes .../speech_exceptions.cpython-313.pyc | Bin 0 -> 2054 bytes .../tais_kb_exceptions.cpython-313.pyc | Bin 0 -> 1798 bytes .../clients/exceptions/abrt_exceptions.py | 106 ++ .../exceptions/emulator_rag_exceptions.py | 30 + .../clients/exceptions/imdb_exceptions.py | 45 + .../exceptions/portability_exceptions.py | 105 ++ .../clients/exceptions/siebel_exceptions.py | 80 ++ .../clients/exceptions/speech_exceptions.py | 33 + .../clients/exceptions/tais_kb_exceptions.py | 30 + src/components/clients/imdb_client.py | 222 ++++ src/components/clients/portability_client.py | 226 +++++ src/components/clients/rag/__init__.py | 17 + .../rag/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 858 bytes .../history_rag_client.cpython-313.pyc | Bin 0 -> 8091 bytes .../templates_rag_client.cpython-313.pyc | Bin 0 -> 4952 bytes .../clients/rag/history_rag_client.py | 168 ++++ .../clients/rag/templates_rag_client.py | 96 ++ src/components/clients/siebel_client.py | 355 +++++++ .../clients/speech_analytics_client.py | 332 ++++++ src/components/clients/tais_kb_client.py | 813 +++++++++++++++ src/components/tools/__init__.py | 10 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 404 bytes .../__pycache__/example_tool.cpython-313.pyc | Bin 0 -> 3329 bytes src/components/tools/example_tool.py | 98 ++ src/core/.DS_Store | Bin 0 -> 6148 bytes src/core/__init__.py | 41 + src/core/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 875 bytes src/core/__pycache__/config.cpython-313.pyc | Bin 0 -> 24752 bytes .../__pycache__/exceptions.cpython-313.pyc | Bin 0 -> 2373 bytes src/core/__pycache__/logging.cpython-313.pyc | Bin 0 -> 25958 bytes .../prompt_manager.cpython-313.pyc | Bin 0 -> 3523 bytes .../__pycache__/state_logger.cpython-313.pyc | Bin 0 -> 569 bytes src/core/config.py | 370 +++++++ src/core/exceptions.py | 34 + src/core/logging.py | 523 ++++++++++ src/core/prompt_manager.py | 82 ++ src/core/state_logger.py | 15 + .../__pycache__/client.cpython-313.pyc | Bin 0 -> 1137 bytes src/core/streaming/client.py | 20 + src/infrastructure/.DS_Store | Bin 0 -> 6148 bytes src/infrastructure/oci/autonomous/__init__.py | 3 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 328 bytes .../__pycache__/cases_manager.cpython-313.pyc | Bin 0 -> 2934 bytes .../__pycache__/connection.cpython-313.pyc | Bin 0 -> 11174 bytes .../memory_manager.cpython-313.pyc | Bin 0 -> 5708 bytes .../oci/autonomous/cases_manager.py | 67 ++ .../oci/autonomous/connection.py | 202 ++++ .../oci/autonomous/memory_manager.py | 116 +++ .../__pycache__/client.cpython-313.pyc | Bin 0 -> 6666 bytes .../__pycache__/consumer.cpython-313.pyc | Bin 0 -> 9212 bytes .../debug_producer.cpython-313.pyc | Bin 0 -> 3115 bytes .../__pycache__/producer.cpython-313.pyc | Bin 0 -> 3239 bytes src/infrastructure/streaming/client.py | 107 ++ src/infrastructure/streaming/consumer.py | 166 +++ .../streaming/debug_producer.py | 41 + src/infrastructure/streaming/producer.py | 54 + .../langfuse_provider.cpython-313.pyc | Bin 0 -> 4854 bytes .../__pycache__/llm_provider.cpython-313.pyc | Bin 0 -> 9728 bytes src/providers/langfuse_provider.py | 112 +++ src/providers/llm_provider.py | 216 ++++ src/utils/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 235 bytes .../cancellation_helpers.cpython-313.pyc | Bin 0 -> 1923 bytes .../decision_helpers.cpython-313.pyc | Bin 0 -> 2849 bytes .../external_response_builder.cpython-313.pyc | Bin 0 -> 3067 bytes .../forwarding_helpers.cpython-313.pyc | Bin 0 -> 8943 bytes src/utils/__pycache__/http.cpython-313.pyc | Bin 0 -> 8268 bytes .../__pycache__/ics_collector.cpython-313.pyc | Bin 0 -> 27664 bytes src/utils/__pycache__/loaders.cpython-313.pyc | Bin 0 -> 743 bytes .../__pycache__/observer.cpython-313.pyc | Bin 0 -> 29099 bytes src/utils/__pycache__/text.cpython-313.pyc | Bin 0 -> 833 bytes src/utils/cancellation_helpers.py | 43 + src/utils/decision_helpers.py | 82 ++ src/utils/external_response_builder.py | 53 + src/utils/forwarding_helpers.py | 179 ++++ src/utils/http.py | 177 ++++ src/utils/ics_collector.py | 675 +++++++++++++ src/utils/loaders.py | 11 + src/utils/observer.py | 643 ++++++++++++ src/utils/text.py | 23 + tests_original_develop/.DS_Store | Bin 0 -> 6148 bytes tests_original_develop/__init__.py | 1 + tests_original_develop/conftest.py | 37 + tests_original_develop/fixtures/__init__.py | 1 + tests_original_develop/fixtures/mock_llm.py | 155 +++ .../integration/__init__.py | 1 + tests_original_develop/property/__init__.py | 1 + tests_original_develop/unit/__init__.py | 1 + .../unit/test_anatel_validation.py | 141 +++ .../unit/test_canceling_analysis_duplicate.py | 461 +++++++++ tests_original_develop/unit/test_config.py | 439 ++++++++ .../unit/test_emulator_close_case_node.py | 272 +++++ .../unit/test_identity_verification.py | 352 +++++++ .../unit/test_imdb_client.py | 288 ++++++ .../unit/test_imdb_enrichment.py | 187 ++++ .../unit/test_knowledge_base_enrichment.py | 238 +++++ .../unit/test_llm_factory.py | 228 +++++ tests_original_develop/unit/test_llm_retry.py | 50 + tests_original_develop/unit/test_logging.py | 420 ++++++++ .../unit/test_siebel_classification_node.py | 164 +++ .../unit/test_siebel_client.py | 389 +++++++ .../unit/test_siebel_sr_opening_node.py | 215 ++++ .../unit/test_validation.py | 120 +++ tests_original_develop/verify_prompts.py | 53 + .../validate_parity.cpython-313.pyc | Bin 0 -> 5973 bytes tools/validate_parity.py | 157 +++ 439 files changed, 32801 insertions(+) create mode 100644 .env create mode 100644 .env.backoffice_mcp.example create mode 100644 .env.example create mode 100644 .idea/.gitignore create mode 100644 .idea/backoffice_convertido_framework.iml create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .oca/custom_code_review_guidelines.txt create mode 100644 Dockerfile create mode 100644 README_BACKOFFICE_CONVERTIDO_100.md create mode 100644 README_BACKOFFICE_FRAMEWORK_NATIVE_100.md create mode 100644 README_BACKOFFICE_NATIVE_FRAMEWORK.md create mode 100644 README_ENTERPRISE_TEMPLATE.md create mode 100644 README_MIGRACAO_BACKOFFICE.md create mode 100644 app/.DS_Store create mode 100644 app/__init__.py create mode 100644 app/__pycache__/__init__.cpython-313.pyc create mode 100644 app/__pycache__/identity_extraction.cpython-313.pyc create mode 100644 app/__pycache__/main.cpython-313.pyc create mode 100644 app/__pycache__/state.cpython-313.pyc create mode 100644 app/agents/README.md create mode 100644 app/agents/__pycache__/backoffice_agent.cpython-313.pyc create mode 100644 app/agents/__pycache__/billing_agent.cpython-313.pyc create mode 100644 app/agents/__pycache__/orders_agent.cpython-313.pyc create mode 100644 app/agents/__pycache__/product_agent.cpython-313.pyc create mode 100644 app/agents/__pycache__/prompting.cpython-313.pyc create mode 100644 app/agents/__pycache__/runtime.cpython-313.pyc create mode 100644 app/agents/__pycache__/support_agent.cpython-313.pyc create mode 100644 app/agents/backoffice_agent.py create mode 100644 app/agents/billing_agent.py create mode 100644 app/agents/orders_agent.py create mode 100644 app/agents/product_agent.py create mode 100644 app/agents/prompting.py create mode 100644 app/agents/runtime.py create mode 100644 app/agents/support_agent.py create mode 100644 app/domain/.DS_Store create mode 100644 app/domain/__init__.py create mode 100644 app/domain/__pycache__/__init__.cpython-313.pyc create mode 100644 app/domain/backoffice/__init__.py create mode 100644 app/domain/backoffice/__pycache__/__init__.cpython-313.pyc create mode 100644 app/examples/__init__.py create mode 100644 app/examples/__pycache__/__init__.cpython-313.pyc create mode 100644 app/examples/__pycache__/grl_examples.cpython-313.pyc create mode 100644 app/examples/__pycache__/ic_examples.cpython-313.pyc create mode 100644 app/examples/__pycache__/mcp_examples.cpython-313.pyc create mode 100644 app/examples/__pycache__/noc_examples.cpython-313.pyc create mode 100644 app/examples/__pycache__/observer_examples.cpython-313.pyc create mode 100644 app/examples/grl_examples.py create mode 100644 app/examples/ic_examples.py create mode 100644 app/examples/mcp_examples.py create mode 100644 app/examples/noc_examples.py create mode 100644 app/examples/observer_examples.py create mode 100644 app/identity_extraction.py create mode 100644 app/main.py create mode 100644 app/state.py create mode 100644 app/workflows/__pycache__/agent_graph.cpython-313.pyc create mode 100644 app/workflows/__pycache__/backoffice_native_runtime.cpython-313.pyc create mode 100644 app/workflows/agent_graph.py create mode 100644 app/workflows/backoffice_native_runtime.py create mode 100644 config/.DS_Store create mode 100644 config/agents.yaml create mode 100644 config/agents/.DS_Store create mode 100644 config/agents/backoffice_anatel/guardrails.yaml create mode 100644 config/agents/backoffice_anatel/judges.yaml create mode 100644 config/agents/backoffice_anatel/prompt_policy.yaml create mode 100644 config/agents/retail_orders/guardrails.yaml create mode 100644 config/agents/retail_orders/judges.yaml create mode 100644 config/agents/retail_orders/prompt_policy.yaml create mode 100644 config/agents/telecom_contas/guardrails.yaml create mode 100644 config/agents/telecom_contas/judges.yaml create mode 100644 config/agents/telecom_contas/prompt_policy.yaml create mode 100644 config/guardrails.yaml create mode 100644 config/identity.yaml create mode 100644 config/judges.yaml create mode 100644 config/mcp_parameter_mapping.yaml create mode 100644 config/mcp_servers.docker.yaml create mode 100644 config/mcp_servers.yaml create mode 100644 config/prompt_policy.yaml create mode 100644 config/routing.yaml create mode 100644 config/tools.yaml create mode 100644 configs_original_develop/README.md create mode 100644 configs_original_develop/config.example.yaml create mode 100644 curl_test.txt create mode 100644 ddl/README.md create mode 100644 ddl/anatel_notas.sql create mode 100644 ddl/create-collections.sql create mode 100644 ddl/manual_anatel.sql create mode 100644 ddl/templates.sql create mode 100644 ddl_original_develop/README.md create mode 100644 ddl_original_develop/anatel_notas.sql create mode 100644 ddl_original_develop/create-collections.sql create mode 100644 ddl_original_develop/manual_anatel.sql create mode 100644 ddl_original_develop/templates.sql create mode 100644 docker-compose.yml create mode 100644 docs/AJUSTES_IC_NOC_GRL_OCI_OPENAI.md create mode 100644 docs/ATUALIZACAO_TEMPLATE_ANALYTICS_OUTPUT_SUPERVISOR.md create mode 100644 docs/COMO_USAR_IC_NOC_GRL_NO_TEMPLATE.md create mode 100644 docs/CORRECAO_ENV_MCP_TIM_CLIENTS.md create mode 100644 docs/CORRECAO_EXTRACAO_CPF_TEXTO_LIVRE.md create mode 100644 docs/CORRECAO_IMPORTS_AGENT_FRAMEWORK_7.md create mode 100644 docs/CORRECAO_MCP_PARAMETROS_CPF_EXPLICITO.md create mode 100644 docs/CORRECAO_PROTOCOL_ID_MCP_MAPPING.md create mode 100644 docs/GUARDRAILS_PARALLELOS_OBSERVER_IC.md create mode 100644 docs/IMPLEMENTACAO_IC_NOC_GRL_SEM_REMOVER_LOGICA.md create mode 100644 docs/OPCAO_B_LLM_PROVIDER_TELEMETRY.md create mode 100644 docs/RELATORIO_FRAMEWORK_NATIVE_100.md create mode 100644 docs/RELATORIO_PARIDADE_BACKOFFICE_100.md create mode 100644 docs/VALIDACAO_BACKEND_IC_NOC_GRL.md create mode 100644 docs/VALIDACAO_TEMPLATE_ENTERPRISE.txt create mode 100644 docs_original_develop/README.md create mode 100644 docs_original_develop/api-clients.md create mode 100644 docs_original_develop/ics.md create mode 100644 docs_original_develop/logging.md create mode 100644 legacy_reference_disabled/.DS_Store create mode 100644 legacy_reference_disabled/original_develop/.DS_Store create mode 100644 legacy_reference_disabled/original_develop/src_agent_graphs/__init__.py create mode 100644 legacy_reference_disabled/original_develop/src_agent_graphs/emulator_graph.py create mode 100644 legacy_reference_disabled/original_develop/src_agent_graphs/llm_testing_graph.py create mode 100644 legacy_reference_disabled/original_develop/src_agent_graphs/main_graph.py create mode 100644 legacy_reference_disabled/original_develop/src_api_executors/__init__.py create mode 100644 legacy_reference_disabled/original_develop/src_api_executors/checklist.py create mode 100644 legacy_reference_disabled/original_develop/src_api_executors/response_emulator.py create mode 100644 legacy_reference_disabled/original_develop/src_api_main.py create mode 100644 legacy_reference_disabled/original_develop/src_api_routes/__init__.py create mode 100644 legacy_reference_disabled/original_develop/src_api_routes/agent.py create mode 100644 legacy_reference_disabled/original_develop/src_api_routes/emulator.py create mode 100644 legacy_reference_disabled/original_develop/src_api_routes/emulator_rag.py create mode 100644 legacy_reference_disabled/original_develop/src_api_routes/health.py create mode 100644 mcp_servers/.DS_Store create mode 100644 mcp_servers/backoffice_mcp_server/README.md create mode 100644 mcp_servers/backoffice_mcp_server/__init__.py create mode 100644 mcp_servers/backoffice_mcp_server/__pycache__/__init__.cpython-313.pyc create mode 100644 mcp_servers/backoffice_mcp_server/__pycache__/clients.cpython-313.pyc create mode 100644 mcp_servers/backoffice_mcp_server/__pycache__/main.cpython-313.pyc create mode 100644 mcp_servers/backoffice_mcp_server/__pycache__/models.cpython-313.pyc create mode 100644 mcp_servers/backoffice_mcp_server/__pycache__/settings.cpython-313.pyc create mode 100644 mcp_servers/backoffice_mcp_server/__pycache__/store.cpython-313.pyc create mode 100644 mcp_servers/backoffice_mcp_server/clients.py create mode 100644 mcp_servers/backoffice_mcp_server/main.py create mode 100644 mcp_servers/backoffice_mcp_server/models.py create mode 100644 mcp_servers/backoffice_mcp_server/settings.py create mode 100644 mcp_servers/backoffice_mcp_server/store.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 scripts/run_backoffice_mcp.sh create mode 100644 scripts_original_develop/.gitkeep create mode 100644 scripts_original_develop/get_prompt_from_langfuse.py create mode 100644 scripts_original_develop/upload_prompts_to_langfuse.py create mode 100644 sh_original_develop/run_export.sh create mode 100644 src/.DS_Store create mode 100644 src/__init__.py create mode 100644 src/__pycache__/__init__.cpython-313.pyc create mode 100644 src/__pycache__/__init__.cpython-39.pyc create mode 100644 src/agent/.DS_Store create mode 100644 src/agent/__init__.py create mode 100644 src/agent/__pycache__/__init__.cpython-313.pyc create mode 100644 src/agent/__pycache__/__init__.cpython-39.pyc create mode 100644 src/agent/graphs_README_DISABLED.md create mode 100644 src/agent/local_prompts/__pycache__/canceling_analysis.cpython-313.pyc create mode 100644 src/agent/local_prompts/__pycache__/duplicate_analysis.cpython-313.pyc create mode 100644 src/agent/local_prompts/__pycache__/postprocess_tais_kb_query.cpython-313.pyc create mode 100644 src/agent/local_prompts/__pycache__/preprocess_tais_kb_query.cpython-313.pyc create mode 100644 src/agent/local_prompts/__pycache__/speech_history_analysis.cpython-313.pyc create mode 100644 src/agent/local_prompts/__pycache__/ticket_reclassification.cpython-313.pyc create mode 100644 src/agent/local_prompts/__pycache__/tim_complaint_analysis.cpython-313.pyc create mode 100644 src/agent/local_prompts/__pycache__/treatment_decision_prompt.cpython-313.pyc create mode 100644 src/agent/local_prompts/anatel_motives/__pycache__/anatel_motives.cpython-313.pyc create mode 100644 src/agent/local_prompts/anatel_motives/anatel_motives.py create mode 100644 src/agent/local_prompts/canceling_analysis.py create mode 100644 src/agent/local_prompts/duplicate_analysis.py create mode 100644 src/agent/local_prompts/emulator/__init__.py create mode 100644 src/agent/local_prompts/emulator/__pycache__/__init__.cpython-313.pyc create mode 100644 src/agent/local_prompts/emulator/__pycache__/response_emulator_generation.cpython-313.pyc create mode 100644 src/agent/local_prompts/emulator/response_emulator_generation.py create mode 100644 src/agent/local_prompts/postprocess_tais_kb_query.py create mode 100644 src/agent/local_prompts/preprocess_tais_kb_query.py create mode 100644 src/agent/local_prompts/speech_history_analysis.py create mode 100644 src/agent/local_prompts/ticket_reclassification.py create mode 100644 src/agent/local_prompts/tim_complaint_analysis.py create mode 100644 src/agent/local_prompts/treatment_decision_prompt.py create mode 100644 src/agent/logic/__pycache__/validation.cpython-313.pyc create mode 100644 src/agent/logic/validation.py create mode 100644 src/agent/nodes/__init__.py create mode 100644 src/agent/nodes/__pycache__/__init__.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/__init__.cpython-39.pyc create mode 100644 src/agent/nodes/__pycache__/bypass_rules_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/cache_check_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/canceling_analysis_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/different_complaint_operator_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/different_complaint_operator_node.cpython-39.pyc create mode 100644 src/agent/nodes/__pycache__/fetch_ticket_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/identity_verification_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/imdb_enrichment_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/knowledge_base_enrichment_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/reclassification_analysis_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/siebel_sr_opening_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/siebel_triplet_classification_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/speech_enrichment_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/tim_complaint_analysis_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/tim_complaint_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/treatment_decision_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/undefined_complaint_operator_node.cpython-313.pyc create mode 100644 src/agent/nodes/__pycache__/validation_node.cpython-313.pyc create mode 100644 src/agent/nodes/bypass_rules_node.py create mode 100644 src/agent/nodes/cache_check_node.py create mode 100644 src/agent/nodes/canceling_analysis_node.py create mode 100644 src/agent/nodes/different_complaint_operator_node.py create mode 100644 src/agent/nodes/emulator/__init__.py create mode 100644 src/agent/nodes/emulator/__pycache__/__init__.cpython-313.pyc create mode 100644 src/agent/nodes/emulator/__pycache__/_rag_query.cpython-313.pyc create mode 100644 src/agent/nodes/emulator/__pycache__/approve_draft_node.cpython-313.pyc create mode 100644 src/agent/nodes/emulator/__pycache__/close_case_node.cpython-313.pyc create mode 100644 src/agent/nodes/emulator/__pycache__/fetch_case_node.cpython-313.pyc create mode 100644 src/agent/nodes/emulator/__pycache__/generate_response_node.cpython-313.pyc create mode 100644 src/agent/nodes/emulator/__pycache__/persist_draft_node.cpython-313.pyc create mode 100644 src/agent/nodes/emulator/__pycache__/retrieve_history_node.cpython-313.pyc create mode 100644 src/agent/nodes/emulator/__pycache__/retrieve_templates_node.cpython-313.pyc create mode 100644 src/agent/nodes/emulator/__pycache__/router_node.cpython-313.pyc create mode 100644 src/agent/nodes/emulator/__pycache__/start_response_emulation_node.cpython-313.pyc create mode 100644 src/agent/nodes/emulator/__pycache__/validate_actions_node.cpython-313.pyc create mode 100644 src/agent/nodes/emulator/__pycache__/validate_response_node.cpython-313.pyc create mode 100644 src/agent/nodes/emulator/_rag_query.py create mode 100644 src/agent/nodes/emulator/approve_draft_node.py create mode 100644 src/agent/nodes/emulator/close_case_node.py create mode 100644 src/agent/nodes/emulator/fetch_case_node.py create mode 100644 src/agent/nodes/emulator/generate_response_node.py create mode 100644 src/agent/nodes/emulator/persist_draft_node.py create mode 100644 src/agent/nodes/emulator/retrieve_history_node.py create mode 100644 src/agent/nodes/emulator/retrieve_templates_node.py create mode 100644 src/agent/nodes/emulator/router_node.py create mode 100644 src/agent/nodes/emulator/start_response_emulation_node.py create mode 100644 src/agent/nodes/emulator/validate_actions_node.py create mode 100644 src/agent/nodes/emulator/validate_response_node.py create mode 100644 src/agent/nodes/fetch_ticket_node.py create mode 100644 src/agent/nodes/identity_verification_node.py create mode 100644 src/agent/nodes/imdb_enrichment_node.py create mode 100644 src/agent/nodes/knowledge_base_enrichment_node.py create mode 100644 src/agent/nodes/reclassification_analysis_node.py create mode 100644 src/agent/nodes/siebel_sr_opening_node.py create mode 100644 src/agent/nodes/siebel_triplet_classification_node.py create mode 100644 src/agent/nodes/speech_enrichment_node.py create mode 100644 src/agent/nodes/tim_complaint_analysis_node.py create mode 100644 src/agent/nodes/tim_complaint_node.py create mode 100644 src/agent/nodes/treatment_decision_node.py create mode 100644 src/agent/nodes/undefined_complaint_operator_node.py create mode 100644 src/agent/nodes/validation_node.py create mode 100644 src/agent/state/__init__.py create mode 100644 src/agent/state/__pycache__/__init__.cpython-313.pyc create mode 100644 src/agent/state/__pycache__/__init__.cpython-39.pyc create mode 100644 src/agent/state/__pycache__/agent_state.cpython-313.pyc create mode 100644 src/agent/state/__pycache__/agent_state.cpython-39.pyc create mode 100644 src/agent/state/__pycache__/step_helpers.cpython-313.pyc create mode 100644 src/agent/state/__pycache__/step_helpers_emulator.cpython-313.pyc create mode 100644 src/agent/state/__pycache__/step_notes.cpython-313.pyc create mode 100644 src/agent/state/__pycache__/step_notes_emulator.cpython-313.pyc create mode 100644 src/agent/state/__pycache__/steps.cpython-313.pyc create mode 100644 src/agent/state/__pycache__/steps_emulator.cpython-313.pyc create mode 100644 src/agent/state/__pycache__/transitions_emulator.cpython-313.pyc create mode 100644 src/agent/state/agent_state.py create mode 100644 src/agent/state/step_helpers.py create mode 100644 src/agent/state/step_helpers_emulator.py create mode 100644 src/agent/state/step_notes.py create mode 100644 src/agent/state/step_notes_emulator.py create mode 100644 src/agent/state/steps.py create mode 100644 src/agent/state/steps_emulator.py create mode 100644 src/agent/state/transitions_emulator.py create mode 100644 src/api/.DS_Store create mode 100644 src/api/LEGACY_ROUTES_DISABLED.md create mode 100644 src/api/__init__.py create mode 100644 src/api/__pycache__/__init__.cpython-313.pyc create mode 100644 src/api/dependencies/__init__.py create mode 100644 src/api/dependencies/__pycache__/__init__.cpython-313.pyc create mode 100644 src/api/dependencies/__pycache__/logging_context.cpython-313.pyc create mode 100644 src/api/dependencies/logging_context.py create mode 100644 src/api/middleware/__init__.py create mode 100644 src/api/middleware/__pycache__/__init__.cpython-313.pyc create mode 100644 src/api/middleware/__pycache__/error_handler.cpython-313.pyc create mode 100644 src/api/middleware/__pycache__/logging.cpython-313.pyc create mode 100644 src/api/middleware/error_handler.py create mode 100644 src/api/middleware/logging.py create mode 100644 src/api/schemas/__init__.py create mode 100644 src/api/schemas/__pycache__/__init__.cpython-313.pyc create mode 100644 src/api/schemas/__pycache__/abrt_schemas.cpython-313.pyc create mode 100644 src/api/schemas/__pycache__/anatel_response_emulator_schemas.cpython-313.pyc create mode 100644 src/api/schemas/__pycache__/anatel_schemas.cpython-313.pyc create mode 100644 src/api/schemas/__pycache__/emulator_rag_schemas.cpython-313.pyc create mode 100644 src/api/schemas/__pycache__/imdb_schemas.cpython-313.pyc create mode 100644 src/api/schemas/__pycache__/portability_schemas.cpython-313.pyc create mode 100644 src/api/schemas/__pycache__/request.cpython-313.pyc create mode 100644 src/api/schemas/__pycache__/response.cpython-313.pyc create mode 100644 src/api/schemas/__pycache__/siebel_schemas.cpython-313.pyc create mode 100644 src/api/schemas/__pycache__/tais_kb_schemas.cpython-313.pyc create mode 100644 src/api/schemas/abrt_schemas.py create mode 100644 src/api/schemas/anatel_response_emulator_schemas.py create mode 100644 src/api/schemas/anatel_schemas.py create mode 100644 src/api/schemas/emulator_rag_schemas.py create mode 100644 src/api/schemas/imdb_schemas.py create mode 100644 src/api/schemas/portability_schemas.py create mode 100644 src/api/schemas/request.py create mode 100644 src/api/schemas/response.py create mode 100644 src/api/schemas/siebel_schemas.py create mode 100644 src/api/schemas/tais_kb_schemas.py create mode 100644 src/api/utils/__pycache__/agent_helpers.cpython-313.pyc create mode 100644 src/api/utils/__pycache__/emulator_response_builder.cpython-313.pyc create mode 100644 src/api/utils/agent_helpers.py create mode 100644 src/api/utils/emulator_response_builder.py create mode 100644 src/compat/__init__.py create mode 100644 src/compat/__pycache__/__init__.cpython-313.pyc create mode 100644 src/compat/__pycache__/framework_observer.cpython-313.pyc create mode 100644 src/compat/framework_observer.py create mode 100644 src/components/.DS_Store create mode 100644 src/components/__init__.py create mode 100644 src/components/__pycache__/__init__.cpython-313.pyc create mode 100644 src/components/checkpointing/__init__.py create mode 100644 src/components/checkpointing/__pycache__/__init__.cpython-313.pyc create mode 100644 src/components/checkpointing/__pycache__/mongodb_saver.cpython-313.pyc create mode 100644 src/components/checkpointing/mongodb_saver.py create mode 100644 src/components/clients/__pycache__/abrt_client.cpython-313.pyc create mode 100644 src/components/clients/__pycache__/emulator_rag_client.cpython-313.pyc create mode 100644 src/components/clients/__pycache__/imdb_client.cpython-313.pyc create mode 100644 src/components/clients/__pycache__/portability_client.cpython-313.pyc create mode 100644 src/components/clients/__pycache__/siebel_client.cpython-313.pyc create mode 100644 src/components/clients/__pycache__/speech_analytics_client.cpython-313.pyc create mode 100644 src/components/clients/__pycache__/tais_kb_client.cpython-313.pyc create mode 100644 src/components/clients/abrt_client.py create mode 100644 src/components/clients/emulator_rag_client.py create mode 100644 src/components/clients/exceptions/__pycache__/abrt_exceptions.cpython-313.pyc create mode 100644 src/components/clients/exceptions/__pycache__/emulator_rag_exceptions.cpython-313.pyc create mode 100644 src/components/clients/exceptions/__pycache__/imdb_exceptions.cpython-313.pyc create mode 100644 src/components/clients/exceptions/__pycache__/portability_exceptions.cpython-313.pyc create mode 100644 src/components/clients/exceptions/__pycache__/siebel_exceptions.cpython-313.pyc create mode 100644 src/components/clients/exceptions/__pycache__/speech_exceptions.cpython-313.pyc create mode 100644 src/components/clients/exceptions/__pycache__/tais_kb_exceptions.cpython-313.pyc create mode 100644 src/components/clients/exceptions/abrt_exceptions.py create mode 100644 src/components/clients/exceptions/emulator_rag_exceptions.py create mode 100644 src/components/clients/exceptions/imdb_exceptions.py create mode 100644 src/components/clients/exceptions/portability_exceptions.py create mode 100644 src/components/clients/exceptions/siebel_exceptions.py create mode 100644 src/components/clients/exceptions/speech_exceptions.py create mode 100644 src/components/clients/exceptions/tais_kb_exceptions.py create mode 100644 src/components/clients/imdb_client.py create mode 100644 src/components/clients/portability_client.py create mode 100644 src/components/clients/rag/__init__.py create mode 100644 src/components/clients/rag/__pycache__/__init__.cpython-313.pyc create mode 100644 src/components/clients/rag/__pycache__/history_rag_client.cpython-313.pyc create mode 100644 src/components/clients/rag/__pycache__/templates_rag_client.cpython-313.pyc create mode 100644 src/components/clients/rag/history_rag_client.py create mode 100644 src/components/clients/rag/templates_rag_client.py create mode 100644 src/components/clients/siebel_client.py create mode 100644 src/components/clients/speech_analytics_client.py create mode 100644 src/components/clients/tais_kb_client.py create mode 100644 src/components/tools/__init__.py create mode 100644 src/components/tools/__pycache__/__init__.cpython-313.pyc create mode 100644 src/components/tools/__pycache__/example_tool.cpython-313.pyc create mode 100644 src/components/tools/example_tool.py create mode 100644 src/core/.DS_Store create mode 100644 src/core/__init__.py create mode 100644 src/core/__pycache__/__init__.cpython-313.pyc create mode 100644 src/core/__pycache__/config.cpython-313.pyc create mode 100644 src/core/__pycache__/exceptions.cpython-313.pyc create mode 100644 src/core/__pycache__/logging.cpython-313.pyc create mode 100644 src/core/__pycache__/prompt_manager.cpython-313.pyc create mode 100644 src/core/__pycache__/state_logger.cpython-313.pyc create mode 100644 src/core/config.py create mode 100644 src/core/exceptions.py create mode 100644 src/core/logging.py create mode 100644 src/core/prompt_manager.py create mode 100644 src/core/state_logger.py create mode 100644 src/core/streaming/__pycache__/client.cpython-313.pyc create mode 100644 src/core/streaming/client.py create mode 100644 src/infrastructure/.DS_Store create mode 100644 src/infrastructure/oci/autonomous/__init__.py create mode 100644 src/infrastructure/oci/autonomous/__pycache__/__init__.cpython-313.pyc create mode 100644 src/infrastructure/oci/autonomous/__pycache__/cases_manager.cpython-313.pyc create mode 100644 src/infrastructure/oci/autonomous/__pycache__/connection.cpython-313.pyc create mode 100644 src/infrastructure/oci/autonomous/__pycache__/memory_manager.cpython-313.pyc create mode 100644 src/infrastructure/oci/autonomous/cases_manager.py create mode 100644 src/infrastructure/oci/autonomous/connection.py create mode 100644 src/infrastructure/oci/autonomous/memory_manager.py create mode 100644 src/infrastructure/streaming/__pycache__/client.cpython-313.pyc create mode 100644 src/infrastructure/streaming/__pycache__/consumer.cpython-313.pyc create mode 100644 src/infrastructure/streaming/__pycache__/debug_producer.cpython-313.pyc create mode 100644 src/infrastructure/streaming/__pycache__/producer.cpython-313.pyc create mode 100644 src/infrastructure/streaming/client.py create mode 100644 src/infrastructure/streaming/consumer.py create mode 100644 src/infrastructure/streaming/debug_producer.py create mode 100644 src/infrastructure/streaming/producer.py create mode 100644 src/providers/__pycache__/langfuse_provider.cpython-313.pyc create mode 100644 src/providers/__pycache__/llm_provider.cpython-313.pyc create mode 100644 src/providers/langfuse_provider.py create mode 100644 src/providers/llm_provider.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/__pycache__/__init__.cpython-313.pyc create mode 100644 src/utils/__pycache__/cancellation_helpers.cpython-313.pyc create mode 100644 src/utils/__pycache__/decision_helpers.cpython-313.pyc create mode 100644 src/utils/__pycache__/external_response_builder.cpython-313.pyc create mode 100644 src/utils/__pycache__/forwarding_helpers.cpython-313.pyc create mode 100644 src/utils/__pycache__/http.cpython-313.pyc create mode 100644 src/utils/__pycache__/ics_collector.cpython-313.pyc create mode 100644 src/utils/__pycache__/loaders.cpython-313.pyc create mode 100644 src/utils/__pycache__/observer.cpython-313.pyc create mode 100644 src/utils/__pycache__/text.cpython-313.pyc create mode 100644 src/utils/cancellation_helpers.py create mode 100644 src/utils/decision_helpers.py create mode 100644 src/utils/external_response_builder.py create mode 100644 src/utils/forwarding_helpers.py create mode 100644 src/utils/http.py create mode 100644 src/utils/ics_collector.py create mode 100644 src/utils/loaders.py create mode 100644 src/utils/observer.py create mode 100644 src/utils/text.py create mode 100644 tests_original_develop/.DS_Store create mode 100644 tests_original_develop/__init__.py create mode 100644 tests_original_develop/conftest.py create mode 100644 tests_original_develop/fixtures/__init__.py create mode 100644 tests_original_develop/fixtures/mock_llm.py create mode 100644 tests_original_develop/integration/__init__.py create mode 100644 tests_original_develop/property/__init__.py create mode 100644 tests_original_develop/unit/__init__.py create mode 100644 tests_original_develop/unit/test_anatel_validation.py create mode 100644 tests_original_develop/unit/test_canceling_analysis_duplicate.py create mode 100644 tests_original_develop/unit/test_config.py create mode 100644 tests_original_develop/unit/test_emulator_close_case_node.py create mode 100644 tests_original_develop/unit/test_identity_verification.py create mode 100644 tests_original_develop/unit/test_imdb_client.py create mode 100644 tests_original_develop/unit/test_imdb_enrichment.py create mode 100644 tests_original_develop/unit/test_knowledge_base_enrichment.py create mode 100644 tests_original_develop/unit/test_llm_factory.py create mode 100644 tests_original_develop/unit/test_llm_retry.py create mode 100644 tests_original_develop/unit/test_logging.py create mode 100644 tests_original_develop/unit/test_siebel_classification_node.py create mode 100644 tests_original_develop/unit/test_siebel_client.py create mode 100644 tests_original_develop/unit/test_siebel_sr_opening_node.py create mode 100644 tests_original_develop/unit/test_validation.py create mode 100644 tests_original_develop/verify_prompts.py create mode 100644 tools/__pycache__/validate_parity.cpython-313.pyc create mode 100644 tools/validate_parity.py diff --git a/.env b/.env new file mode 100644 index 0000000..da9f8f2 --- /dev/null +++ b/.env @@ -0,0 +1,239 @@ +############################################################################### +# AI AGENT PLATFORM - CONFIGURAÇÃO ÚNICA +# Este arquivo é lido por Pydantic Settings no framework e no backend template. +############################################################################### + +APP_NAME=ai-agent-template +APP_ENV=local +LOG_LEVEL=INFO +API_HOST=0.0.0.0 +API_PORT=8000 +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +############################################################################### +# LLM - OCI Generative AI como provider principal +############################################################################### +# Opções: mock, oci_openai, oci_sdk, openai_compatible +LLM_PROVIDER=oci_openai +LLM_TEMPERATURE=0.2 +LLM_MAX_TOKENS=2048 +LLM_TIMEOUT_SECONDS=120 + +# OCI OpenAI-compatible endpoint +OCI_GENAI_BASE_URL=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1 +OCI_GENAI_MODEL=openai.gpt-4.1 +OCI_GENAI_API_KEY=sk-ph3FgX6iP3fxAQCXb9IpPIDTadkeeYAWntUWhzcWysIM6zsS +OCI_GENAI_PROJECT_OCID= + +# OCI SDK / signer / profiles +OCI_CONFIG_FILE=~/.oci/config +OCI_PROFILE=LATINOAMERICA-Chicago +OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaexpiw4a7dio64mkfv2t273s2hgdl6mgfvvyv7tycalnjlvpvfl3q +OCI_REGION=us-chicago-1 + +############################################################################### +# Persistência +############################################################################### +# Opções: memory, autonomous, mongodb +SESSION_REPOSITORY_PROVIDER=autonomous +MEMORY_REPOSITORY_PROVIDER=autonomous +CHECKPOINT_REPOSITORY_PROVIDER=autonomous + +# Autonomous Database +ADB_USER=admin +ADB_PASSWORD=Moniquinha1972 +ADB_DSN=oradb23ai_high +ADB_WALLET_LOCATION=/mnt/d/Dropbox/ORACLE/LatinoAmerica/Wallet_ORADB23ai +ADB_WALLET_PASSWORD=Moniquinha1972 +ADB_TABLE_PREFIX=AGENTFW + +# MongoDB - também pode representar Autonomous usando API compatível com Mongo, se habilitada no ambiente +MONGODB_URI=mongodb://mongo:mongopassword@localhost:27017 +MONGODB_DATABASE=agent_platform + +# Redis +REDIS_URL=redis://localhost:6379/0 +ENABLE_REDIS_CACHE=false + +############################################################################### +# RAG / Vector / Graph +############################################################################### +VECTOR_STORE_PROVIDER=memory +GRAPH_STORE_PROVIDER=memory +RAG_TOP_K=5 +EMBEDDING_PROVIDER=mock +OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0 + +############################################################################### +# Observabilidade +############################################################################### +ENABLE_LANGFUSE=true +LANGFUSE_PUBLIC_KEY=pk-lf-bd9b0c7e-2b8b-4e5b-a382-284a9b4413b3 +LANGFUSE_SECRET_KEY=sk-lf-5f5cc18d-0bb5-424e-b5d0-cb3664d58c20 +LANGFUSE_HOST=http://localhost:3005 +ENABLE_OTEL=false +OTEL_EXPORTER_OTLP_ENDPOINT= +OTEL_SERVICE_NAME=ai-agent-template + +############################################################################### +# Analytics / Observer corporativo +############################################################################### +# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo. +ENABLE_ANALYTICS=false +# Providers aceitos: oci_streaming,pubsub,noop +ANALYTICS_PROVIDERS=oci_streaming +# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente. +AGENT_PUBSUB_TOPIC= +GCP_PUBSUB_TOPIC_PATH= +GCP_PROJECT_ID= +GCP_PUBSUB_TOPIC= +GCP_PUBSUB_TIMEOUT_SECONDS=30 +# Credencial GCP segue padrão Google: +# GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json + +############################################################################### +# OCI Streaming +############################################################################### +ENABLE_OCI_STREAMING=false +OCI_STREAM_ENDPOINT= +OCI_STREAM_OCID= +OCI_STREAM_PARTITION_KEY=agent-events + +############################################################################### +# Guardrails, Judges, Supervisor +############################################################################### +ENABLE_INPUT_GUARDRAILS=true +ENABLE_OUTPUT_GUARDRAILS=true +ENABLE_JUDGES=true +ENABLE_SUPERVISOR=true +ENABLE_OUTPUT_SUPERVISOR=true +ENABLE_PARALLEL_GUARDRAILS=true +GUARDRAILS_FAIL_FAST=true +OUTPUT_SUPERVISOR_MAX_RETRIES=3 +GUARDRAILS_CONFIG_PATH=./config/guardrails.yaml +JUDGES_CONFIG_PATH=./config/judges.yaml +PROMPT_POLICY_PATH=./config/prompt_policy.yaml + +############################################################################### +# Gateway de canais +############################################################################### +DEFAULT_CHANNEL=web +ENABLE_VOICE_ADAPTER=true +ENABLE_WHATSAPP_ADAPTER=true +ENABLE_TEXT_ADAPTER=true + +################################################# +# ENTERPRISE ROUTING +################################################# +# Arquivo YAML com intents, keywords, políticas de estado e fallback. +ROUTING_CONFIG_PATH=./config/routing.yaml +# true = usa LLM para classificar quando keywords/estado não resolverem. +# Em produção, costuma ser útil; em desenvolvimento, false evita custo e latência. +ENABLE_LLM_ROUTER=true + +############################################################################### +# MCP / Tools +############################################################################### +ENABLE_MCP_TOOLS=true +MCP_SERVERS_CONFIG_PATH=./config/mcp_servers.yaml +TOOLS_CONFIG_PATH=./config/tools.yaml +MCP_TOOL_TIMEOUT_SECONDS=30 + +# router = EnterpriseRouter seleciona um agente; supervisor = pode acionar múltiplos agentes +ROUTING_MODE=router + +# Usage/cost accounting +USAGE_REPOSITORY_PROVIDER=autonomous +IDENTITY_CONFIG_PATH=./config/identity.yaml +MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml + +############################################################################### +# BACKOFFICE CONVERTIDO - CHAVES ADICIONADAS A PARTIR DO .env.example +# Valores existentes no .env original foram preservados. Revise placeholders. +############################################################################### + +# Framework +DEFAULT_AGENT_ID=backoffice_anatel +ENABLE_SSE=true +SQLITE_DB_PATH=.local/backoffice_framework.db +LLM_MODEL=cohere.command-r-08-2024 +# OCI OpenAI-compatible endpoint/credentials. Preencha conforme seu Agent Framework. +OCI_OPENAI_BASE_URL=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions/chat +OCI_OPENAI_API_KEY=sk-ph3FgX6iP3fxAQCXb9IpPIDTadkeeYAWntUWhzcWysIM6zsS +# OPENAI_API_KEY= +OCI_CONFIG_PROFILE=LATINOAMERICA-Chicago +# OCI_REGION=us-chicago-1 +# OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaexpiw4a7dio64mkfv2t273s2hgdl6mgfvvyv7tycalnjlvpvfl3q + +# MCP +MCP_ENABLED=true +DEBUG=true +# IMDB_API_HOST= +# SIEBEL_API_HOST= +# SIEBEL_API_ROUTE= +# SIEBEL_STATUS_API_HOST= +# SIEBEL_PREPAGO_STATUS_API_HOST= +# SPEECH_ANALYTICS_API_HOST= +# TAIS_KB_DB_USER= +# TAIS_KB_DB_PASSWORD= +# TAIS_KB_DSN= +# OCI_REQUEST_STREAM_OCID= +# OCI_RESPONSE_STREAM_OCID= +# OCI_CONSUMER_GROUP_NAME= + +# MCP Server local do backoffice +# O backend principal chama esse endpoint pelo MCPToolRouter do framework. +BACKOFFICE_MCP_BASE_URL=http://localhost:8010 +BACKOFFICE_MCP_USE_MOCK=false +BACKOFFICE_MCP_BACKEND_TYPE=tim_clients +BACKOFFICE_MCP_FAIL_OPEN_ON_BACKEND_ERROR=true + +# TIM/develop original - preencha no .env real, nunca versionar segredos. +PMID_API_HOST=https://pmidfqa.internal.timbrasil.com.br/access/v1/info +PMID_API_BASIC_TOKEN=Basic dGlteDpAckp1SkpAcFJPUiM= +PMID_API_TIMEOUT=10 +PMID_API_CLIENT_ID=AIAGENTCR +SIEBEL_API_HOST=https://pmidfqa.internal.timbrasil.com.br/customers/v1/backOfficeSRopening +SIEBEL_API_TIMEOUT=10 +SIEBEL_API_USERNAME=aiagentcr +SIEBEL_API_PASSWORD=AiAgentCR#FQA# +SIEBEL_API_CLIENT_ID=AIAGENTCR +SIEBEL_STATUS_API_HOST=https://pmidfqa.internal.timbrasil.com.br +SIEBEL_STATUS_API_ROUTE=/interactions/v1/statusServiceRequest +SIEBEL_PREPAGO_STATUS_API_HOST=https://pmidfqa.internal.timbrasil.com.br +SIEBEL_PREPAGO_STATUS_API_ROUTE=/customers/v1/serviceRequest +VERIFY_SSL=false + +SPEECH_PREDICTION_BASE_URL=https://apigatewayfqa1.tim.com.br +SPEECH_PREDICTION_CLIENT_ID=TzlzCbMGNKpGXl7oUDAlE66eL3ZGmWBtuHMjgsClMoDNdmLz +SPEECH_PREDICTION_CLIENT_SECRET=ZIgIIxsn3CG7ra9HiOLSLygRzcgcivIMsoo4kwqLo9MtQBtNnmUadNpUx81ABkEm +SPEECH_HISTORY_BASE_URL=https://run-external-speech-analytics-audio-toxico-49911170294.us-east1.run.app +SPEECH_HISTORY_AUDIENCE=https://run-external-speech-analytics-audio-toxico-49911170294.us-east1.run.app +GOOGLE_APPLICATION_CREDENTIALS=/Users/cristianohoshikawa/Dropbox/ORACLE/TIM/compass/lab_backoffice/config/gcp-credentials-local.json +SPEECH_TIMEOUT=30 +SPEECH_SIMILARITY_THRESHOLD=70 + +TAIS_DB_USER=USR_ADB_AGNTATEND_W_DEV +TAIS_DB_PASSWORD=T!M#Esta026! +TAIS_DB_DSN="(description= (retry_count=3)(retry_delay=7)(address=(protocol=tcps)(port=1522)(host=10.152.100.72))(connect_data=(service_name=gf9a4a2e79cfeb2_agntatendimentodev_high.adb.oraclecloud.com))(security=(ssl_server_dn_match=no)))" +TAIS_DB_TIMEOUT=30 +TAIS_GENAI_ENDPOINT=https://inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com +TAIS_GENAI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaa3prjvf7mkvijwn5ng5h6n5ftanbbwqfg6cu44kmffwamhyy267iq +TAIS_GENAI_EMBED_MODEL_ID=cohere.embed-multilingual-v3.0 +TAIS_TABLE_CHUNKS=CHUNKS_CHAR_COHERE_3 +TAIS_TOP_K=3 + +# LLM CLASSIFICATION +CLASSIFICATION_LLM_MODEL=bo_gptoss20b_dev +CLASSIFICATION_LLM_TEMPERATURE=0.3 +CLASSIFICATION_LLM_MAX_TOKENS=2000 +CLASSIFICATION_LLM_TOP_P=0.9 +CLASSIFICATION_LLM_TOP_K=250 + +CLASSIFICATION_LARGE_LLM_MODEL=bo_gptoss120b_dev +CLASSIFICATION_LARGE_LLM_TEMPERATURE=0.3 +CLASSIFICATION_LARGE_LLM_MAX_TOKENS=4000 +CLASSIFICATION_LARGE_LLM_TOP_P=0.9 +CLASSIFICATION_LARGE_LLM_TOP_K=250 + +USE_FULL_ANATEL_DICT=true diff --git a/.env.backoffice_mcp.example b/.env.backoffice_mcp.example new file mode 100644 index 0000000..5d93f83 --- /dev/null +++ b/.env.backoffice_mcp.example @@ -0,0 +1,29 @@ +# ========================================================== +# Backoffice MCP Server +# Copie para .env.backoffice_mcp ou injete via Docker/K8s Secret. +# ========================================================== + +BACKOFFICE_MCP_HOST=0.0.0.0 +BACKOFFICE_MCP_PORT=8010 +BACKOFFICE_MCP_LOG_LEVEL=info + +# true = usa dados simulados em memória. +# false = usa BACKOFFICE_MCP_BACKEND_TYPE para chamar backend real. +BACKOFFICE_MCP_USE_MOCK=true +BACKOFFICE_MCP_BACKEND_TYPE=mock + +# REST backend opcional +BACKOFFICE_MCP_REST_BASE_URL=http://localhost:8080/backoffice +BACKOFFICE_MCP_REST_TIMEOUT_SECONDS=20 +BACKOFFICE_MCP_REST_AUTH_HEADER=Authorization +BACKOFFICE_MCP_REST_API_KEY= + +# Oracle backend opcional +BACKOFFICE_MCP_ORACLE_USER= +BACKOFFICE_MCP_ORACLE_PASSWORD= +BACKOFFICE_MCP_ORACLE_DSN= +BACKOFFICE_MCP_ORACLE_WALLET_LOCATION= +BACKOFFICE_MCP_ORACLE_WALLET_PASSWORD= + +# Se true, erro no backend real vira resposta controlada da tool. +BACKOFFICE_MCP_FAIL_OPEN_ON_BACKEND_ERROR=false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5e005b1 --- /dev/null +++ b/.env.example @@ -0,0 +1,98 @@ +# Backoffice convertido 100% para execução dentro do Agent Framework +# Nunca versionar .env com credenciais reais. + +APP_ENV=local +LOG_LEVEL=INFO +CORS_ORIGINS=* + +# Framework +DEFAULT_AGENT_ID=backoffice_anatel +ROUTING_MODE=router +ENABLE_SSE=true +ENABLE_PARALLEL_GUARDRAILS=true +GUARDRAILS_FAIL_FAST=true + +# Repositórios locais por padrão +SESSION_REPOSITORY_PROVIDER=sqlite +MEMORY_REPOSITORY_PROVIDER=sqlite +CHECKPOINT_REPOSITORY_PROVIDER=sqlite +USAGE_REPOSITORY_PROVIDER=sqlite +SQLITE_DB_PATH=.local/backoffice_framework.db + +# LLM +LLM_PROVIDER=oci_openai +LLM_MODEL=cohere.command-r-08-2024 +# OCI OpenAI-compatible endpoint/credentials. Preencha conforme seu Agent Framework. +# OCI_OPENAI_BASE_URL=https://inference.generativeai..oci.oraclecloud.com/20231130/actions/chat +# OCI_OPENAI_API_KEY= +# OPENAI_API_KEY= +# OCI_CONFIG_PROFILE=DEFAULT +# OCI_REGION=sa-saopaulo-1 +# OCI_COMPARTMENT_ID= + +# MCP +MCP_ENABLED=true +MCP_SERVERS_CONFIG_PATH=config/mcp_servers.yaml +MCP_PARAMETER_MAPPING_PATH=config/mcp_parameter_mapping.yaml + +# Original develop / TIM integrations +ENABLE_OCI_STREAMING=false +DEBUG=true +# IMDB_API_HOST= +# SIEBEL_API_HOST= +# SIEBEL_API_ROUTE= +# SIEBEL_STATUS_API_HOST= +# SIEBEL_PREPAGO_STATUS_API_HOST= +# SPEECH_ANALYTICS_API_HOST= +# TAIS_KB_DB_USER= +# TAIS_KB_DB_PASSWORD= +# TAIS_KB_DSN= +# OCI_REQUEST_STREAM_OCID= +# OCI_RESPONSE_STREAM_OCID= +# OCI_CONSUMER_GROUP_NAME= + + +# MCP Server local do backoffice +# O backend principal chama esse endpoint pelo MCPToolRouter do framework. +BACKOFFICE_MCP_BASE_URL=http://localhost:8010 +BACKOFFICE_MCP_USE_MOCK=false +BACKOFFICE_MCP_BACKEND_TYPE=tim_clients +BACKOFFICE_MCP_FAIL_OPEN_ON_BACKEND_ERROR=true + +# TIM/develop original - preencha no .env real, nunca versionar segredos. +PMID_API_HOST=https://pmidfqa.internal.timbrasil.com.br/access/v1/info +PMID_API_BASIC_TOKEN= +PMID_API_TIMEOUT=10 +PMID_API_CLIENT_ID=AIAGENTCR + +SIEBEL_API_HOST=https://pmidfqa.internal.timbrasil.com.br/customers/v1/backOfficeSRopening +SIEBEL_API_TIMEOUT=10 +SIEBEL_API_USERNAME= +SIEBEL_API_PASSWORD= +SIEBEL_API_CLIENT_ID=AIAGENTCR +SIEBEL_STATUS_API_HOST=https://pmidfqa.internal.timbrasil.com.br +SIEBEL_STATUS_API_ROUTE=/interactions/v1/statusServiceRequest +SIEBEL_PREPAGO_STATUS_API_HOST=https://pmidfqa.internal.timbrasil.com.br +SIEBEL_PREPAGO_STATUS_API_ROUTE=/customers/v1/serviceRequest +VERIFY_SSL=false + +SPEECH_PREDICTION_BASE_URL=https://apigatewayfqa1.tim.com.br +SPEECH_PREDICTION_CLIENT_ID= +SPEECH_PREDICTION_CLIENT_SECRET= +SPEECH_HISTORY_BASE_URL= +SPEECH_HISTORY_AUDIENCE= +GOOGLE_APPLICATION_CREDENTIALS=/path/to/gcp-credentials-local.json +SPEECH_TIMEOUT=30 +SPEECH_SIMILARITY_THRESHOLD=70 + +TAIS_DB_USER= +TAIS_DB_PASSWORD= +TAIS_DB_DSN= +TAIS_DB_TIMEOUT=30 +TAIS_GENAI_ENDPOINT=https://inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com +TAIS_GENAI_COMPARTMENT_ID= +TAIS_GENAI_EMBED_MODEL_ID=cohere.embed-multilingual-v3.0 +TAIS_TABLE_CHUNKS=CHUNKS_CHAR_COHERE_3 +TAIS_TOP_K=3 + +USE_FULL_ANATEL_DICT=true diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..7d9a8e5 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,12 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Environment-dependent path to Maven home directory +/mavenHomeManager.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Zeppelin ignored files +/ZeppelinRemoteNotebooks/ diff --git a/.idea/backoffice_convertido_framework.iml b/.idea/backoffice_convertido_framework.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/backoffice_convertido_framework.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..919ce1f --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..a55e7a1 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..89ee753 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..735d56d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.oca/custom_code_review_guidelines.txt b/.oca/custom_code_review_guidelines.txt new file mode 100644 index 0000000..a0a3b63 --- /dev/null +++ b/.oca/custom_code_review_guidelines.txt @@ -0,0 +1,24 @@ +# Sample guideline, please follow similar structure for guideline with code samples +# 1. Suggest using streams instead of simple loops for better readability. +# +# *Comment: +# Category: Minor +# Issue: Use streams instead of a loop for better readability. +# Code Block: +# +# ```java +# // Calculate squares of numbers +# List squares = new ArrayList<>(); +# for (int number : numbers) { +# squares.add(number * number); +# } +# ``` +# Recommendation: +# +# ```java +# // Calculate squares of numbers +# List squares = Arrays.stream(numbers) +# .map(n -> n * n) // Map each number to its square +# .toList(); +# ``` +# diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..273fe01 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.12-slim +WORKDIR /app +COPY agent_framework /agent_framework +COPY agent_template_backend /app +RUN pip install --no-cache-dir -e /agent_framework -r requirements.txt +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README_BACKOFFICE_CONVERTIDO_100.md b/README_BACKOFFICE_CONVERTIDO_100.md new file mode 100644 index 0000000..b2707dd --- /dev/null +++ b/README_BACKOFFICE_CONVERTIDO_100.md @@ -0,0 +1,156 @@ +# Backoffice TIM/ANATEL convertido para o Agent Framework — versão 100% de paridade + +Esta versão foi gerada a partir de dois insumos: + +- `tim-ai-atend-agnt-backoffice(3).zip`: branch `develop` do backoffice original. +- `backoffice_convertido_framework(2).zip`: primeira versão convertida para o framework. + +## O que mudou + +A versão anterior tinha uma implementação genérica de `BackofficeAgent` com MCP tools e endpoints de compatibilidade simplificados. Esta versão preserva os fluxos reais da branch `develop` como workflows de domínio dentro da aplicação do framework. + +## Camadas mantidas pelo framework + +O framework continua responsável por: + +- `/gateway/message` e `/gateway/events/{session_id}`. +- Normalização de canais. +- `AgentIdentity` e `BusinessContext`. +- Sessão, memória, checkpoint e uso. +- `EnterpriseRouter` e modo supervisor. +- Guardrails de entrada e saída. +- OutputSupervisor. +- Judges. +- Telemetria, IC, NOC e GRL. +- MCP Tool Router. + +## Fluxos originais preservados + +O pacote `src/` foi copiado da branch `develop` e preserva os grafos originais: + +### Checklist ANATEL + +Arquivo: `src/agent/graphs/main_graph.py` + +Fluxo preservado: + +```text +fetch_ticket + -> validation + -> bypass_rules + -> cache_check + -> imdb_enrichment + -> identity_verification + -> speech_enrichment + -> knowledge_base_enrichment + -> canceling_analysis + -> tim_complaint_analysis + -> tim_complaint / different_complaint_operator / undefined_complaint_operator + -> reclassification_analysis + -> treatment_decision + -> siebel_sr_opening +``` + +### Response Emulator + +Arquivo: `src/agent/graphs/emulator_graph.py` + +Fluxo preservado: + +```text +start_response_emulation + -> fetch_case + -> validate_actions + -> router + -> retrieve_templates + -> retrieve_history + -> generate_response + -> validate_response + -> persist_draft + -> approve_draft / close_case +``` + +## Rotas preservadas do backoffice original + +Registradas em `app/main.py`: + +```text +POST /agent/execute +POST /agent/process-ticket +POST /agent/process-and-stream +GET /agent/search-tais-kb +POST /case/{transaction_id}/response-emulator/generate +POST /case/{transaction_id}/response-emulator/finalize +GET /case/{transaction_id}/response-emulator +GET /emulator-rag/search +GET /health/live +GET /health/ready +``` + +## Serviços TIM preservados + +Os clients originais foram preservados em `src/components/clients/`: + +```text +imdb_client.py +siebel_client.py +speech_analytics_client.py +tais_kb_client.py +abrt_client.py +portability_client.py +emulator_rag_client.py +rag/history_rag_client.py +rag/templates_rag_client.py +``` + +## Prompts preservados + +Os prompts originais foram preservados em `src/agent/local_prompts/`: + +```text +canceling_analysis.py +duplicate_analysis.py +tim_complaint_analysis.py +ticket_reclassification.py +treatment_decision_prompt.py +speech_history_analysis.py +preprocess_tais_kb_query.py +postprocess_tais_kb_query.py +emulator/response_emulator_generation.py +anatel_motives/anatel_motives.py +``` + +## Guardrails e judges + +A configuração do agente backoffice foi reforçada em: + +```text +config/agents/backoffice_anatel/guardrails.yaml +config/agents/backoffice_anatel/judges.yaml +config/agents/backoffice_anatel/prompt_policy.yaml +``` + +A lógica de domínio original continua fazendo validações operacionais por nó, e o framework envolve a execução com guardrails, OutputSupervisor e judges. + +## Como subir localmente + +```bash +cp .env.example .env +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +## Como validar rapidamente + +```bash +curl http://localhost:8000/health +curl http://localhost:8000/debug/backoffice/parity +curl http://localhost:8000/health/live +curl http://localhost:8000/health/ready +``` + +## Observação importante + +O arquivo `.env` real foi removido do pacote gerado. Configure credenciais reais somente localmente ou via secret manager. Não versionar chaves OCI, Autonomous Database, Langfuse, Siebel, IMDB, Speech Analytics ou qualquer segredo TIM. diff --git a/README_BACKOFFICE_FRAMEWORK_NATIVE_100.md b/README_BACKOFFICE_FRAMEWORK_NATIVE_100.md new file mode 100644 index 0000000..31c9c30 --- /dev/null +++ b/README_BACKOFFICE_FRAMEWORK_NATIVE_100.md @@ -0,0 +1,77 @@ +# Backoffice TIM/ANATEL convertido 100% para o framework + +Esta versão substitui a execução direta dos grafos legados por workflows compilados pelo runtime do framework. + +## Princípio aplicado + +- O framework é dono do motor de workflow/LangGraph, checkpoint, telemetry, guardrails, judges, supervisor e persistência de execução. +- O backend fornece apenas customizações de domínio: nós, services/clients, prompts, schemas e contratos REST. +- Os contratos REST antigos continuam existindo, mas agora são adapters finos para `BackofficeNativeRuntime.execute_workflow(...)`. + +## O que foi removido do caminho ativo + +Os seguintes módulos do develop foram movidos para referência e não são mais importáveis pelo backend ativo: + +- `src/agent/graphs/*` +- `src/api/executors/*` +- `src/api/routes/*` +- `src/api/main.py` + +Eles ficam em: + +```text +legacy_reference_disabled/original_develop/ +``` + +## Runtime ativo + +```text +app/workflows/backoffice_native_runtime.py +``` + +Esse runtime monta dois workflows com o motor do framework: + +```text +backoffice_checklist +backoffice_response_emulator +``` + +Cada workflow inclui os nós de domínio do backoffice original, mas cercados por etapas do framework: + +```text +framework_input_guardrails +... +framework_output_supervisor +framework_output_guardrails +framework_judges +framework_supervisor_review +framework_persist +``` + +## Rotas preservadas como adapters framework-native + +```text +POST /agent/process-ticket +POST /agent/execute +POST /agent/process-and-stream +POST /agent/search-tais-kb +POST /case/{transaction_id}/response-emulator/generate +POST /case/{transaction_id}/response-emulator/finalize +GET /case/{transaction_id}/response-emulator +POST /emulator-rag/search +GET /health/live +GET /health/ready +``` + +## Validação estrutural + +Execute: + +```bash +python tools/validate_parity.py +python -m compileall -q app src tools +``` + +## Observação + +Esta entrega valida a estrutura e compilação Python. Teste funcional ponta-a-ponta ainda depende de serviços TIM/OCI/DB/VPN e variáveis reais. diff --git a/README_BACKOFFICE_NATIVE_FRAMEWORK.md b/README_BACKOFFICE_NATIVE_FRAMEWORK.md new file mode 100644 index 0000000..bf79473 --- /dev/null +++ b/README_BACKOFFICE_NATIVE_FRAMEWORK.md @@ -0,0 +1,64 @@ +# Backoffice/ANATEL migrado para uso nativo do framework + +Esta versão remove o fluxo `backoffice_legacy_flow.py` do caminho ativo do agente. +O agente passa a usar apenas capacidades nativas do framework: + +- LangGraph corporativo do projeto; +- EnterpriseRouter configurado em `config/routing.yaml`; +- `BusinessContext` e `identity.yaml`; +- `MCPToolRouter` e `mcp_parameter_mapping.yaml`; +- RAG via `RagService`; +- cache via `create_cache`; +- IC/NOC/GRL via `AgentObserver`; +- OutputSupervisor, guardrails de saída, judges e persistência do workflow. + +## Arquivo principal + +```text +app/agents/backoffice_agent.py +``` + +O agente não importa mais `app.workflows.backoffice_legacy_flow` e não executa um grafo particular. +Ele recebe `intent`, `route` e `mcp_tools` do roteador nativo e aciona as ferramentas pelo helper +`AgentRuntimeMixin._call_mcp_tool()`. + +## Onde ficam as decisões + +| Decisão | Local correto | +|---|---| +| Roteamento/intents/tools | `config/routing.yaml` | +| Contratos de tool | `config/tools.yaml` | +| Mapeamento de parâmetros | `config/mcp_parameter_mapping.yaml` | +| Isolamento de identidade | `config/identity.yaml` | +| Prompt e regras de redação | `config/agents/backoffice_anatel/prompt_policy.yaml` | +| Guardrails | `config/agents/backoffice_anatel/guardrails.yaml` | +| Judges | `config/agents/backoffice_anatel/judges.yaml` | +| Implementação de integração | `mcp_servers/backoffice_mcp_server/*` | + +## Diferença para a versão anterior + +Antes, o agente executava um fluxo recomposto chamado `BackofficeLegacyFlow`. +Agora, o fluxo do agente é o fluxo padrão do framework: + +```text +input_guardrails + -> routing_decision + -> backoffice_agent + -> output_supervisor + -> output_guardrails + -> judge + -> supervisor_review + -> persist +``` + +## Observação importante + +Regras de negócio que realmente não existem no framework ainda devem entrar como: + +1. configuração YAML; +2. rail/guardrail plugável; +3. judge plugável; +4. tool MCP; +5. serviço de domínio reutilizável do framework. + +Não devem entrar como grafo paralelo dentro do agente. diff --git a/README_ENTERPRISE_TEMPLATE.md b/README_ENTERPRISE_TEMPLATE.md new file mode 100644 index 0000000..cae516e --- /dev/null +++ b/README_ENTERPRISE_TEMPLATE.md @@ -0,0 +1,54 @@ +# Agent Template Backend Enterprise + +Este folder é uma cópia completa do `agent_template_backend`, sem cortes de +arquitetura. Ele mantém workflow, router, output supervisor, guardrails, +analytics, observer, MCP, memória, checkpoints e configurações. + +A diferença é que a lógica de negócio dos agentes de exemplo foi removida da +execução e preservada comentada nos próprios arquivos: + +- `app/agents/billing_agent.py` +- `app/agents/product_agent.py` +- `app/agents/orders_agent.py` +- `app/agents/support_agent.py` + +## O que o desenvolvedor deve alterar + +1. Escolher ou criar um agente em `app/agents/`. +2. Implementar o método `run()`. +3. Ajustar prompts e tools, se necessário. +4. Emitir ICs de negócio relevantes para a jornada. +5. Manter NOC/GRL nos pontos operacionais e de guardrails. + +## O que já está integrado + +- `AgentObserver` +- `observer.emit_ic()` +- `observer.emit_noc()` +- `observer.emit_grl()` +- `AnalyticsPublisher` +- OCI Streaming +- GCP Pub/Sub +- OutputSupervisor +- GuardrailPipeline com suporte a execução paralela/fail-fast no framework +- MCP Tool Router +- LangGraph +- Memory +- Checkpoint +- Langfuse / OpenTelemetry + +## Exemplos adicionados + +Veja `app/examples/`: + +- `ic_examples.py` +- `noc_examples.py` +- `grl_examples.py` +- `mcp_examples.py` +- `observer_examples.py` + +## Convenção rápida + +- IC = evento de negócio / curadoria / informacional. +- NOC = evento operacional / saúde técnica. +- GRL = evento de guardrail / segurança / validação. diff --git a/README_MIGRACAO_BACKOFFICE.md b/README_MIGRACAO_BACKOFFICE.md new file mode 100644 index 0000000..e270d7d --- /dev/null +++ b/README_MIGRACAO_BACKOFFICE.md @@ -0,0 +1,184 @@ +# Backoffice convertido para o seu Agent Framework + +Esta pasta é uma versão convertida do desenho do `tim-ai-atend-agnt-backoffice` para usar o framework local `agent_framework`. + +## O que foi convertido + +O backoffice original foi tratado como uma aplicação FastAPI + LangGraph + nodes + checkpoint MongoDB. A conversão substitui o acoplamento com a dependência corporativa externa `agent-framework` por imports do seu framework local. + +### Mapeamento arquitetural + +| Backoffice original | Versão convertida | +|---|---| +| `src/api/main.py` | `app/main.py` | +| `src/api/routes/agent.py` | `/gateway/message`, `/gateway/message/sse`, `/gateway/events/{session_id}` em `app/main.py` | +| `src/agent/graphs/main_graph.py` | `app/workflows/agent_graph.py` | +| `src/agent/state/agent_state.py` | `app/state.py` | +| `src/agent/nodes/router_node.py` | `EnterpriseRouter` via `config/routing.yaml` | +| `src/agent/nodes/llm_node.py` | `BackofficeAgent` + `create_llm(settings)` | +| `src/agent/nodes/tool_node.py` | `MCPToolRouter` + `config/tools.yaml` | +| `src/components/checkpointing/mongodb_saver.py` | `agent_framework.checkpoints` / `create_langgraph_checkpointer` | +| `configs/config.example.yaml` | `.env` + `config/*.yaml` | + +## Arquivos principais adicionados + +- `app/agents/backoffice_agent.py` +- `config/agents.yaml` +- `config/routing.yaml` +- `config/tools.yaml` +- `config/mcp_servers.yaml` +- `config/mcp_parameter_mapping.yaml` +- `config/identity.yaml` +- `config/agents/backoffice_anatel/*` + +## Execução local + +Coloque esta pasta ao lado do projeto `agent_framework`: + +```text +workspace/ + agent_framework/ + backoffice_convertido_framework/ +``` + +Instale dependências: + +```bash +cd backoffice_convertido_framework +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +pip install -e ../agent_framework +``` + +Suba a aplicação: + +```bash +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +Teste: + +```bash +curl -s -X POST http://localhost:8000/gateway/message \ + -H 'Content-Type: application/json' \ + -d '{ + "channel":"web", + "agent_id":"backoffice_anatel", + "tenant_id":"default", + "payload":{ + "text":"Preciso analisar uma reclamação da ANATEL do protocolo 12345", + "session_id":"teste-bko-001", + "protocol_id":"12345", + "customer_key":"11999999999" + } + }' | jq +``` + +## Pontos que ainda exigem o ZIP real do backoffice + +Esta conversão preserva a arquitetura e cria um backend funcional no seu padrão, mas não migra regras de negócio específicas que não estavam disponíveis no ambiente atual. Quando o ZIP real do backoffice for anexado, os seguintes itens devem ser migrados arquivo por arquivo: + +1. Tools reais de `src/components/tools/*` para `config/tools.yaml` e/ou adaptadores MCP. +2. Regras específicas dos nodes antigos para `BackofficeAgent` ou agentes especializados. +3. Schemas reais da API para compatibilidade retroativa. +4. Checkpoint MongoDB customizado, se houver semântica diferente do saver do framework. +5. Testes unitários e integração do backoffice original. + +## Decisão de design + +A migração usa um único agente `backoffice_agent`, porque a análise estrutural indicava que o backoffice era mais um runtime/boilerplate de agente do que um domínio funcional completo. Caso o ZIP real mostre subdomínios claros, o próximo passo é quebrar em agentes especializados: + +- `anatel_triage_agent` +- `customer_lookup_agent` +- `case_resolution_agent` +- `action_register_agent` + +## Complemento: MCP do backoffice migrado + +A versão atual inclui também um servidor MCP/HTTP próprio do backoffice em: + +```text +mcp_servers/backoffice_mcp_server/ +``` + +Esse servidor substitui a lógica que antes ficaria em `src/components/tools/*` no backoffice original. Ele expõe o contrato consumido pelo `MCPToolRouter` do framework: + +```text +GET /health +GET /tools/list +POST /tools/call +``` + +Ferramentas migradas inicialmente: + +```text +consultar_reclamacao +consultar_cliente_backoffice +registrar_acao_backoffice +``` + +### Rodar API e MCP separados + +Terminal 1: + +```bash +./scripts/run_backoffice_mcp.sh +``` + +Terminal 2: + +```bash +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +### Testar MCP diretamente + +```bash +curl -s http://localhost:8010/tools/list | jq + +curl -s -X POST http://localhost:8010/tools/call \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "consultar_reclamacao", + "arguments": { + "protocol_id": "12345", + "customer_key": "11999999999", + "interaction_key": "12345" + } + }' | jq +``` + +### Rodar com Docker Compose + +```bash +docker compose up --build +``` + +O compose sobe dois serviços: + +```text +backoffice-api -> porta 8000 +backoffice-mcp -> porta 8010 +``` + +A API aponta para o MCP usando: + +```text +BACKOFFICE_MCP_BASE_URL=http://backoffice-mcp:8010 +``` + +### Onde colocar serviços reais + +Hoje o MCP usa dados simulados em: + +```text +mcp_servers/backoffice_mcp_server/store.py +``` + +Para plugar sistemas reais, substitua as funções desse arquivo por chamadas para APIs, bancos, filas ou SDKs internos. Mantenha o contrato das ferramentas para não quebrar: + +```text +config/tools.yaml +config/mcp_parameter_mapping.yaml +``` diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..3c412d750ed9f25d8671c531e5ccb8667e9fabc1 GIT binary patch literal 10244 zcmeHMeQXp(6rZ;(WtXya%ZKzzk%KBWT0&c(dht{>Ne9DBD*yJ2tlyxqGi zRFWD$FwqcW{Pqu{{);Fkn)s239}xpFB32S5QHdso5QtIy$3Kj3X12B4UeSL{6nB!H zxAW$`nfHGC-ptJ$Ap}};S|cH0LWqbDrOHgKiWJ&UpGigVI8{gi^$AH46`uj3lf)$H zfDs@NAP^uBAP^uBAnWmX~d($oN{#nT%CY7Gk7NyV6&rM zn28gJa2nJB0s#V(5uj)Hay+dkNQNBno!=8?e`73Vr!!pXilxh!5i#-ve0+i>w((wTvAR`BF6!&`=aykoG4OPplE$#`?WmCTri<2E#$1EHjJ zTG@0g<=XgZ4Q?lCOQ zF0|S?OLLfRjCV*_5=v-jsA=Vj`tZsXs}I$OhZ-9i8)>8Y&>=~jSG#=u&cuE*ZP|}- z2*F#sh|Jq#^`rRabIJ!j44$3T@8sLK(MTl z`E6MpYtEMILbpnaDt0=E2_Isayj)TosdGOD23Kv8SBi=zb(v~XH#uvyyjD~?q@FzY zkTW;P8^ubfm*SXIY4pe8?3PfQsC**sp>uCl9d~<1xBj&14pHo-JuRAsOr?EG$JTA3 z&{Qb&IRk1|-(J8VR?dndlfMh)*|*0YI{qfjaP6$u*~K~)!^#_&A^0(PqKKkY8>!=8 zrIr&yYENYN=E|^^6uMY zpaK*KK{eFCLRbpRp#hp;1w^17wm=8;z+O-x1sWKT1qX7Fhaq?X9)!d2FdTuW;Td=i zo`;v=D7*r%!EtySK7^0pV>k)t;9K|(F2E(Y3cm@Z!c1Yl5E80|1;R~2v#?rNBeV#e zLYL4j^ay(eL&ypRVUW8ejOOHo3rpc3_WN_v$$JZp@aj}K*S@8_W2@Z!U&^_!DtMON zFu!UsO}(`n+r|QvhxBL^kIxR45WX~G3Be*`VkC#E7t}1gQ4V)u#^{4ng|0H4=7u;z zrI*yoVPzWT6mQ;Ahs`p~C*G`I77i-{rZI0eHic!S1d~)^y3Nb530`B}dRbOVF(CDaEW}+5@B$3NeQ-Yp`9m1ykHO>c1Uv~(!3*#ryu?EN7z_2+;T?Dv-h&hH z0i1?U;WIb`pTk)=4?nyD(#2)`d-EAO zoo|eMENhvz)sK9dW@SVpHc8`BVGzr=#gWkBiT!>X$(KX?SAuU0zm3IZd^YmKgowW# z2GNQ%{^l|9-SOM9<~Z6iNycx_7#>CBisCj&&TrQVoRUbSfcRv|ezFhkva#j2OgOct zxYQK36&cTu*q9eZBz^V?KT-RF5+D#D5FijBaD5<9!rchd`~OW-|Nnn|I2(8oAP^u> z6agrW#-gp*HF_4FC`Iqu!}#pQhZZU~%qdgBii%J^yN<^Oy^d!W2*~rUcUXVKlN;ug dsiPiKN6K&W|M5Qqg7g2_om+7J|9_tU{{WP4WN!cf literal 0 HcmV?d00001 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0186b348a4832540c6feb5bde1fcb36fdb3c42c9 GIT binary patch literal 184 zcmey&%ge<81cr`ESs?l`h=2h`DC08=kTI1Zok5e)ZzV$!6Oi{ABy}rXKR2&LKSkd+ z*i#{(C_gJTxujS>F+DY}BtESuF*mh5zbHFCKRHuBDKR-aKP@dYIW;~xKd&scs3bEb zAE8D+v7kUdK0Y%qvm`!Vub}c4hfQvNN@-52T@fqL9*{eVL5z>gjEsy$%s>_Zr^YZ> literal 0 HcmV?d00001 diff --git a/app/__pycache__/identity_extraction.cpython-313.pyc b/app/__pycache__/identity_extraction.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b0c8dcf665be85a5e4c691066bc33b910675ac8c GIT binary patch literal 4350 zcmb^!OK%fNy4vk``xU~QA#?(PI3YF($+Cn;c!eEafpuequ(MXD-3bkLx2L)dn@rgC zk#LDeyDUZ=$cmMQ)o@s8E_=@(z-nn?wHOp>=8_vLSRu`6ziPWXP9|aIuqCU)3X_raha!RR~PapzS4p&!V?8gw0?-oF9hG2#$MT|yTrOtA!ZTykIseCbQ{MJIN$ zDD6m?ye#tiw*zs`L8=a8_l7tw8^^ODj@!oZZiwTtaeQ2W^X^1C=IiCqJT!{0V zP=E__j0puN9nr`#5im*_3@^)y#%q!ytD~@{Ps>x&$Ge85XnI^IOr*!tYIkbrM7nou z7@)3n_RGT$(y``GFD&{W%)s2&_W-D-&o-##!ku-<``3`ifpFkK6O#dxiBML^(ZJV^DXRnUZh z>G7flHn8t0U`0~|C8x|-P)5qm2)Fq>pH*hCDC7W22@7A3rTB#xW6F#s33o-UNrJs~ z8>LS7-r{?w5A_}$TLEL4bZ>m@%fknWc0waO%pmc=41q_zzXQNyltf+THbPmznnbC_ z3ZaC<25kU>jp7_Bm$i{ZRyYhcf?1}L)CtPw2|P$J??X{~bec||>oEW_99T4HwKxuw zhNBL{b&t;#MZ=wyCM8W($;B*djK}k`7SG17a2MEXSh*t#ni}UPMOn*CU_LM2SMc48 zB1rLZUbw4FOh|&55fu5Jh&3sztl-4?LLn|?flkt$IIbJ!kfcwMHGw9SD9 z4nB({Nn5xaJ#-Txz6GJ1eVp?EAi&qv_`1oLb0sOxL!gvhgc6^Y+?jOR_pX-Q`G1t$ zyN=|(Rg(K`$%%u_@e7i>k}g~F8we#`pO!Zfw)n1m2OHoj>mLGMbONlrc39Yn*0m>K zdB5c)OQtO70sdhcS_T^5py{2>7KdNZ(VcW#{P;h>EzTr$3?hbyIB1G7_qv*gEQze| zDlLzgTCS%_*uuAO25Hxg$H*~IgNJNN(p(F-$%$mTDKcA$rd+G#*CYk?Wpi;6Hb+Qy zODvSpd!ou_MOGFk7rqrFg}`}HR!RL(*_?C_lXHbFs>OxBV@Zkm{a4uwjUtf{RV4>? zgdo_ZhRyQ@QQ}#F&lePx6`^z$wIYUUt;X09RpZ%-qP+0+!V85}7ryliO1_X2;Yv6t z@KZU3&#J5j)u+fyDhsE|5ZeLeN-gFz9&RFh$bCXi0veHpZpilaACARh9Wg(4gKoYn@XSgPmqMWU3LP}9x9j_%Y-UEB82PU>YFcI0<*{J)_WE+iO0xS6p z;fQ+yffz=82o^4(cL5ZL%ng;xyE*uyQ_iSwa)H`BjtEC z(D5VFIyXE!Tp7?Awsh{ThanuD&;3vR^L@{PdSw5G96xI>@1MQ0!m-EV7~ZuQ?x==4 z<_Gj}SLyS&o^UO){YmJnP~{6f(!LluP>meWBTiy zsyoiq4jg>FX>T>KueP_V+*=K>wf6nxo@$`&gByjmF9zGH!M2K~2ltiEzx4#mv6n7= zQ!f$R{XFnAF#r2!VLj4i@?Q#XUJUQ4hWE@n7sH*^aObPNFU4=V|LxVgPt~^XuJD!d zim&Xl&D6HH+%-Sa}-1HcwNIIC~=Uw)Y{VtKA2d`HNIm)U< zO&$6od97@KZ}-(E{Qghi+r703f9EIg?MHkQKBO26oflpU_KPb;BD=1&t%0Qpc`GFB z?rz~BWObKT^!ZkViomrn09uktvS!>I7$j*8gGE=iBgB~Yfa>ZVH9UDyCFK)$!?EG| zq9|f`KS2f4Afe0x6?GfVtR!d#Jt@|+3KMAzm!ye#6%t$2vf=~}1uyC{JdL7QXXk6A zrZnln>|*(r!#FIY1mk5Y~0tGJs(}L@y?9`ttQTt z&a{8@kS#9?gx)d8<@*pqEj_Oeztp}tsz(M(7nZi}d7^x!e7E($tp6ch^M)6_JFDKE zmBZh8_y31R+q!^#X|x{K4J(6`2oW{SH)92WUmw>g`}LKyBf*RSJ5~tu`q&@>>{zn! z;9ap%1W=<bcn=p3eXR- zFpUAiZwP}eE8FnLb*#b5B>Aa{gq%g)n7m=|Fqu|!$ERQ~8iMYYnIKOHF_Xc>nV9&) za6{LYsgvZUx0ZX`8|Ag{MrqR8@>Hio3K(_)pVEorrngPseSMVd*Vs*7@BP<4 zI0u73+4jqkI@=z6<0~BZcl4q>>7A1qoq z?paRY1b&no;YHp_e$A*x)ZkY;svXgZx_Fpg)FVtcY8Ww!#u1ZfV&VGHtP!(lW`4t{ zWyC64ncp~?Jz^7W%x@aCkK~9s%%3&t7|9iLncqB`H3D-B_pL`Df8P#%SOt@3g)+uR*qDORm`6=>Kv&StC`<1S~F5B)-r$YXx&J?SkL@< zqYWdCV&ljbaSMCTA8i_G7Mn*}#1{5mFxooOCbltu;izk*U2Gre5Ifj=(dgEZZQ?fO zFCN`KvP0a#{3WA1M>@q$<}V%HHFB4D7xR~mc8%;7cQb$aX!pn-aS!uXjP4!j5qp@w za&+Iw-QwLNy<)GEo7PNxUg?^|Zw)tHIPnXKcf}`Eb@43DIVB|VTf+&?oxJ=m4o(~K zUM7Z;9UtjF|LCs8v$VV zo$;WP~Xn8zlJV7>d{8^R8kg4r|(L6KR8`Q@6)5x2ZhEit$1j< zI_3M=^tP1u@#!5Y?}w*5<@myuE{%9(I=4nGmpCz9oB}IMm*QOzn$TX8(-rW$>ATP@ zwCvQ%m5GP63TGrUl8aWI#%beDZ>CuT-GQ^7qp{B29_!{qlO;g=@ zhu-Q``bPQPG?iV`_fGs-hTP*p82P+9@@*LTj)^yJhsKD{QAa$V)E+|8<=rA?p*ROrDZXg{ETkfGc|D)f`W{*-oE-Xz9> zq!_~~F&^5aL?dd9&k3IwM&*`N+h5!y)~QH!0Pa3jGD)7liS&JXSV|by$t{ zq;MpqE~}fwm`I8tq{Mi5lX^_5F}^6cQ(`=_NsOavjB~1UgX`ZxZz9rib?P6Ir*Hefn`V^7CmomHAIdOQ!Y8EiU`i^#xJfFf?wHCi3yUeK z{1RGdi9*CLZIYhNX8*=aHaR~uw6`{oe($``zj9a%oLE@&P59;)f-BX<1;~@_%I8|nhp3* zEX=R6Vw&S|7Z;ZZHvYEcTMCGjeW4OCyX;vB1QtDFV09%a+2J{qZeis#nk7f^qdtK} zWF^`AR)Y(EUohAo@UQqDT>+2<6i z`R)Y-5-WGI6@6w{gakdf1O{hMl*?0Ep&bNc8+uZysbF&H1g((OrA@E1lXL6pI1@K^ z7JsH{4s>n&F>+p!~O zm|QZ(K_v@dJOqQ#wL+S`t~i)$HUQl1Tk*{;{38Y*u&_bKu9A+BlVk~k{#XrS*_-o8 zmN}$D)QD#(_yRBKPAve}=cOcf!V4l{u1P2Mp>$$D97*pJ#egUd!rx?*jB#u&Y3F?_ zk`_2sL{o5L8G-6(ePRd?u^z_K!jh4+tKuR{OB^65Ef!2EYw*!o-!c`Z$sifywUM$| zCH=H^?ITbliZ7yal1Yq{K$6z?=xoq{k52YT$J^bYOM>mQA_$uWuRj1>d2->D_YrUV zK;RKSt&hQWd7V2UdY62UV1@P2aSji$kJMX1O{acJ92MAFkRPDeJkJ zrSCFtm^nw$hJ~|NZg6^g@lQB?Rxx~VtXxj+UVre?f7 zQ^xBB#TWCwa{Lb2c4o}>W<1+0+fO&_$mM6jX|(o}eU718{^fkjKpppT6+htAz3lAm z!po~A{6LNF)lxnAt9Sst>g35^!`wRakW>4rtG8jOO#6C1KU8FRy}&^JGCe-L?$jc} zEYepWOelJ=D#Cr1o90Ab;Baifi`se|A&9_&(|SQi_*~G#XJ9^~Xb?5=ltfM|?9P4JYw#dezM>%&thrIE^>YgfOZ1nnDuy9R>XxF(CoJCHdNK6Pt`|}^1 z#aw@uo5BQ>AFL3`luJA`od)PoS1ZsLDiz}9CkS(-J$HyplmwL|?MaHUjVKPcW`ZcF zv?n87gcT9++D{EHQ#2aC4kb}(hD1h5CAs?!B~e-NN$^|lP?AjWphrL-scf4}^al30`d2DAZa#PsL*P^MbC4Vh>hL6>~QFs0Hv6GZ%`JvH3%trIFWlK2R!PvbXE zRVX2w^rS(dC6d|?3*x3dZ_EJSb_aM<2KWL7&reh;WlQ`fl|mjLSxS7rX{uU*bE~QP zvy$dO=CNm1Qb{_o5NVx1lE=gdxyNUlk@JttR3XtnnB)gac8L|e-Ko40>4-?);Ckzrni-Klk(o) zR=`K@EWt{dx~+nh)|E}--qtQ&Ic|15ZZ?Z+RqFym?)YB_Y^9p2Q~F?nfYKhf#ciBu zN(;oRvO98{1$&p)pPiIS%k9u^wS0bCW^CZjp1`b0f7D#ECx|1E_NZTNpHN0VBRecRYCf`#W@XT$5u=UI~qJz7yWn#TB+VHfdQP^Mkd)SSi?s6PpdxwOL^u5h1@6 z=U3?%?@5pCfOuNmgOW=*GjR@=%9%2$JRIxnYH%e0irRC^cUrQ8Z1xdLhX{BIupqOQ zz${p5*g0s#B}&e;EczZ^2&@L1^x{JlkgcYVC_yPlNgQM>IA{KODF+-eu-bz1#(>hz zlQ}rvMNgs%O~oRO2=P90hzP^xMCPQ)Z9o)~+z_^uhCq(-CiAEyi(z$n4%{Z9Rfxm^ zB>_~&Nmj<7Qc|Vb$iz^9eDwsOT@EZu4z&i@63ARaY#(IbX@W96>|I=78JIx;`4&BM zt4qt0eI=l@Fw3>cE@`prkhDv|dAU|+D4z$&A<74Ym)w;i_N|=cVBED60TFF2^D8BX z@6qMOh1rD!i%lv~THm9#y25YFQOc+=?Ig?0tS5k$7m=deR|WAtQS+c~4hD%qAa8b5 zmr-1j1H435aW#VY3}C6jg3kfLoaNSS?bo#5w0y%7?dZKOMr?g&_pjS>pV@w)`+Rpa zukK1s#8!WHe=H~OLiYLWXwJ-)qUXz=E4w-ssqVtt^|hL>-ulI{&yHOwiQaV}eAj`{)J$~hR2cvB7T?k>t=r3^_NF)OO;;OV z^nc4AwjVls;6p1{Qhss&<Wx4f8?yYa_sr3=cYni`y=%O(R$w- z#=o|F*Akk%KXSl>*U+pl;ye+;cFkBEHI|2saX~(&Anyn zRslj4uBfp+Y-~p^(d`Gq+Yh`^5#4?)y#1IOCDgF%dj4Ccp7)B{E;fdW+O8af6Vm1X zi-|LptQVGs%39?8S*W8gQq~_W8-7WQ6poy;#4P1ei!*F-Ua5>)I>MHYSabLFv2gR~ zxzUTZu(eVB9;)6JwQLVtw#V9fLi-Mf+m4(Y4HY+qtjtMQHlSSCdZJS0o#FD%(60TF@&h4VNla&< z-iwvDM$31F%XdY}yJ96x(UPukNmrz#J666mR$dn^-x)6787bcttE##D_@&2hS`7Jy z4>^P0xPcvGeyGwFvY;OZ8SS4!_7`F7evZigiw=bmIfWqk$;5mFS&U^X{Xu93LSG>y z(%DwYEXd;;1!@)PF7H@AnweNvmKnb>b6!bgRD7wQ$WAXMwvC&!PHEP&X9_JLKT4ho z2?JtJC86C$kgk~+f!a)rK-4J%k&F~FwWaCbRl`hd+B!zs>N2)SCU|UooSa*qalzJh z8L=%XSK4TzO7SIr-6}cm&jP9BOngy%JU5K3eKPwN`KdBa-c}HKi5xPJscxg7JN{_q zRu%NvreG^ePx{CkN3JM6+CzRawMe4$>9PH0w^cM^;nKtgW3*R zrVT6VDn{n=cifgKB@u3-St&{4w;SF$Kpp~%&utM5-F*BV?Qr}%9?c;Th$Q*u&-PpV z)+v=N6!xM$)J<6;z09q&>G;h&CN&f2lk`V0x(x2@0lt$*%Vf^myr^zVHz|ji{4Y2)9VtS3paRX+%O_k!q3KIC|O#LF{2#ziuvV2%a*c`TFE0(cUH<=3= z=iw%EFLRF=-Jjh0xvngK&Qy<5`H5b|m-rR(fI-X|SHR2|BuB{4TqmI*^VqR*+ciRA z=DN|hcAZevWyrLWB$d2BQDVg>6uWai)u_VC_KEYa1ZmxQtr<}Ao!GDV62F;im$^OA z-bP@IQpQZkJZ9|ixoF;e?4b`1B=T2$EbX7CH$Q0%=_f{&v=hGx%qNt&%$VQhE|q1L zKEM4ANCM%X+EdUTNTnC14k{^6j4QsxuiJRc;Wh~snD6n`BRz){41y8As+oJ?Q`dLuh%$2BxoRC+ zT!ziZ?v$X$$c%4|?%WPeXvKWbKrwlEp)GU$@WquG|KLk|X6%749d09B-feW>8`*K;Zh{R2l?HWlPoX|MAD$a?2 zNTE*_Jz;U1;A+Nx#N4fL`LVYkl=B+?p2Od1{LTM7sm~{iwe*Zh<~U0mtVgjI1E9$$ zGoX!-ULz){_y7VKCzTy9i(jT!0$ekbe4~pAc7TJ<_`K{Rab0_eYa>Va3i&9Er6if- zvM`i1#c47m<-ihQ5$>i4U#0I_##0p^g{?Eowe4D6MtLD&5@(>xj9492 z0vvQTd%5@zQR0*!|Q!F2Yn<-{8X5<0U!NNuVqU!aoc9t;Nj zBzr@w>830(KuO+B4i!47O2o=e%XO`QvCv)fV4g#?*AvfZP3E|_DN>wk1_ri!%_xfo z>_$aW8%WYG_?K5#Ag<$`53Uu*({rU4d@YA$WfHyRn-fo=R83}x<;Y^O;u@8bCCr0ON&P?Ay>8#U~3#CQ^NJdm>$&rn_(Nl&5&KGqa#xoUw_u_sB- zTQ#3EJe?p zZ+3xXi>2%bSLc}AZV)FwK@9WbrX*!8#l@0*| z@>IWC#UKQ}?gpG(EH)-D;dRZC7!vWKubJ3*fH1H!VOSG*0o~Lh8RJ!!vit#Y3CA#N zJ~>@>Z1fD}z{UwXEh2(EAv-3rvGV|hv6fwd592K8^PF6r!C@Mc-SyxAM$#ICG+TE>g9`FAxyq`6v6{KJ7$KDo8PcjF{WLKHlRV=Bxo?IL8f&GgM6 zB6*;b0`ZG19V)m76%)_lRX$zHRhw&0Ii!+%@M(&XBcGfl4&&HlkPLF0h?fb5KPB-G zOSU+AgM1HFUE-I-=jf}2M8SO?dDf#mG)BbVrR*o^Glc7?guc080bbQZreu$N;8g6v zq1Y_uz9$U_i6KO!=@$G?CRq}0J*Oy|QytE!zEb;q%X2MPgI_=M!kI`;chuZ{wtxNL zeWClkk%RML-SF81p_*;6qAK}BB2u&s2M@8_f(tF@TcWwOS8}6{En&x&tK|_#XVfwD zo!amCzCHi#*0?NPEgfB1&Rb%+rP18FaBf}9Q4)33gdH`?2Z-Ah6knJ+KNT%#xY8HR zZwcqOTs;)Y-~AJgD>$GD^&F1&Oon?VL++VK&ulz!{t@e&vbUZcirEXEKJ)aMUpn(I8tz`rjOI)8PcJ^Th+~|q)1i@jH#n{zdge%~ zUo%Nb#eep=H6L)C{V09OI;sJf?4HytYCif=vGcO^k~LJnGg90c&FwsA{Jy;qhh`P+ z(efSP@*QuLcYdhXSK8h$?>s;7Om8eN|2J&W;_YGl&)fcio^uocxS%jpaWb5L@&hB@ zpW(l(d#Ai}L(gTGL7=YEhU2uN`VG!hWQL`z%4rLD1&>S#%GxTHB&))Ff&yKK2+ zQGUuQFZW#PxtR@c9Ny$r-?YPbZvRF;S5Y4=cZJJc=ZtTeN|y^yTK5y)bI8 z3frrq_WCQ^Hna$O&)E@mc88ta5$E2Rb7#!C?N%0oSw8<_O>Os3+rH4@2hgsM@DtSH z^n7}QZV%G9?Yf-GAj2ggn$+V#{mgdF^w`^$f`<`=4)VVe6 z+#0KHidJtAS8tEiw8pmF72VPw-qIi0G8o&k_f|FlZyGJxwh#0+yZI-aEz7(?&c9Tm zAW!h>Adw3jdT=@1`%uItMonVKBw{oSYKAo*t?%i3ar9fGp##Swd+v#L-V^G)Cz5;b zo1OPub6nVSe$SPo&+Lid+^+MUbH-5az0~K$7Y>|15GyLZaOV6OYJb-y7wguRa0&X< z8O1SRaZ}9M5i6~Oo7|)~^UOu&bCwT_x%`R?J?DF3*4kK6V_Y6JmS02?q_NzJSZ>Xg z{^v)Z8@<+feM@xrXn6N%sOw;8>!Hw*QAZz8D%`3OW26*<60jPdH<~ z`4%}J=5TrC7uwIahidj*KNZOxiCRZO){*bK@B3=wh1T<}p{jNq#=bNa$vqOa9tq+9 z?EFXTB(T~NE^B#DZEm##fHbz;6|3KM-5#z#5UZ|xzWBM~SQSQ*)ma!TZQan~_r2QY zXzk8$?au3+q1v6H+P+x(wy!__!sD@q&RFZ7SZ(w3`<~kut8I9``?>B|LkGg5t^32R z`y;Kxv8JwQ({Q+HIMOs4Ywf*Ngye2I3LLo~Xp72iKjDfBZRdu5Qh=JVI(|6BbM{hM z_SSX1A$0iOaK}`{HXSuhhfLEz5A&Kp0zHJ5=f#BcVs1z_9f5y@U%6 zX#_$Hhcw4%0Micwk82*Fk$XtvrN9AxhK=J{hCyZ-24U{1=CtOc_Z&qRO3#;G9E&(w zqK@IK4}X2_g|+KjBJKTndr3TJSWg}3&RB6}hW4#-xRn%U-_tL#rjgg9u zSY_Si2QNK%wK!6_E!NQb{DaRu7~0w!Y3NfvR7EPgfFWoCSZM;-X#&`30>JsOhO-w_ z8^x@R?^U{DKp;4eu4rc`#%TFebQ z1?F&$l8X&$c{dysn9Dh;uQWX0{9JRWZEvKeXCse7^V2{JD6o)olwUl`n%NSm*s)PW zfyJDo^5Vmn*DkGHZHrXhwNXNWrEx-pne@H=i?o=;BJNFIQfwylRS+Y>nn^J!ks<_^~8Bb@xkK zBDT?}X*7iY_jy0Ucs`BMszO}77kDb%6^z(cqNbIQX$3=eKbF3aV)mk+d`8FRls$ds zsWZ2#ILn?}m3X_Qp(XGA+|G|b&|yKkwS~)Syk(=XTRI?0z+?q^sq>zX-^{)DRym=N z94>p5m#jx_X)y5;(=LHu{`D4SVAg#+B|WRTWx#N|RfF+y6T$i0-Z727V_Nyi8M!D= zpE-BtXTgtoh?2Wz&HOdqYt?;YAPe4Cck^Dm;ni*}`Fq-DI<&8~@!nj+YpzQ8U$^q! z0>kUs%->S#EjImGmX2J9f!w0LP5}LRHt(%8{JE{S9xs2fo%dE7{$fWjMfs~--dkt* ztGq4b@8-RYhQHcF;omFez0HR2l^MuiUF>Z$e6Opo0WaYy-n-2dc52CATROw*!+Uw} zPE)w29{x9LdGB4OH|zQ;@$$BY_ja4!*6PS_v>?LU$gRilcAk^`+xeMt!`nN0jd=N+ zZhof9@Hcz5VJj(sUyO@jO!QVq)-{%c8C0Xwq`5CkAeUp{^`B@ZR#lE71@YgeU3v*eT@3%6) zi%Rl-hhf%ee7}>Q>9W4R%L@PBX^jY9GqyvKWpU-?EQnV1vBUQ{l0NxYj15^0PT-R` zUUAO73J=c|I5YB-N#Z1v@OYByRkjj0@vEFkBneVG5?>Xc0+nK=2BO!nQj@E|Bz{wD zbtH>$=Of`TrC&M%~F{# zCOFjzN=&N1;K=dIK}>qiDerU*GIkLE z89{D@2Y(+P((nT5kXN)$yR92xN?O`A+;4{-7`L2=Wb zJD?|G@TNVf=tpP1oWGq=zAW>)9heg2128kR6TYuK~uit?!& zR$W<;M*K;No1r0-zJd#r{(Q=HM~#-rLQ7H(IVzy)N&1>$6-xU0zjfWgfU?%fcpbyY zDj6~FJ%eLM#UG&Hl0A;94)%`iA94d?tvLyvH!4XaPelrf^1 zY|2b#nlf5^O?w1h&CK`!Foc3hWfIG$sS%epHZ1*7k_T#VX=Q+L`8%xqkH;=8^*!XZ! z2YAo{q90{gvkffFd!1ge!@WW0_-GR^{w9J!1f33oxxQ9B!E~_ayv{kF6P)-ZuXA=) zT=BYqn=b%RyhzTk!XcX&3bn-e%s4b*qxnw>;yrR+f+OiqEUpGmia(?PqB@Y{@8TyJ ziJR0S{u{m!77hL~JPd<_neg~ik3*XL+UZc=M5t3Z_juGk`KEnxJ)bHG1^H~Bu3;kUH#Jy+dl)85eVeP+WA zt&aRg3xaRt@_km_jl53yUpDgyf7!~D-)88mH@=+D_Z3-RF0hin+KBKqYkXm0XHv7M zzWS(3OAhA5ZEJRhdGa43D$ggZbsSgC{xMPc+T}rrV`Lc52(_xERz1+gCCr!hB#9G& z>Tp6p9k?^^XmjFC@j-0ZSeGoYmO;d4f)GI3bB9`(+@?&eEMy7h zF5aI7;a})LHX=QD78A(e$HsvZ&*HC&ZQ-)V*0LFnYg5ju>7bb%mf?(;&dkKS$$1D4 z&fy;*zlWS9a@Z*)i*N<6l0E44FRb7+b51@(W2b8DfEp*0tIIHpwGhO~WKO~_Yr6sM z{RSoXHFBcl{0=2&X4^IBkFtmQh zlf&MLkZ?58I~~^b;N-BV@x6kIXhCDR0LK;O(fs;wetoR4ge+e~3wK?$r|2S;IImQN zOSaN^;PLavV>sBl>(X7Zyn+o4mw!a_%=`vNzgHW--tt1rwS|{*Bh3S$Ba_i1)8Ql2 z_!i{*iQgXN$A||{KOohD5S|9@hIlwc$u^uF{IHNS<;W^ySF90Z>pGQhOSoW5v|xL< zVEeVfNWt!?efK$Cthg@J-uuQR(_EW78J!D+=K{#)Zp{E?bGK%kvY{UYPH8+8IHfs3 zf%JpG70sg*xT5LRA~61w)2GF!tUfI=zTi3Ui54`63!1O)j1+8*+P9uP5VPb(EtO$Q z<&~k3r7~n`IXeIqu(Jc_3O{==X0e|gz10XV?agv7r~8NYtse&otv%@)GJs1uxKHCPm8Pr4o4u(Chy{T)Z@y z%r+@Y0G5p$G+Ww(eTWWA2kpz#9yOkRszCXK#i>Z~Cw>)3MamA`E2t*}=>_6^D{PG) z0Jw}27#1U$5ts$aUGdGH^e@1^PC{AqA%c7qj$~B?2T0+x$@*#V4NIIMOTscB5(CUo zkB+5axHG~?vfm?P1eqL7lq?6M;y)2mP-(rN3n_p}gB?pr+Q}!FCS74?SH#&(M}9re zk-3{HGY)qu+7h~qa3G>tdlikFKFaWrq&1T-GLt| zE4aF}k(0l~SL%LE@8s8V;*%OY#&)uGm&!)c0-RNL5}w;COGQe1l6V5tP-%}c z+GUndyz;2oeNSwcmmO7NN-Rb*{BcTC6xGOF zxIdbT?i>&A9FOcg61Gf)bQ2#3b;#z)22kW>NlifJs@l&mqkm*v=QAl?uj07(5mjyt zH=j=6c3^J~e1TG=KN)pH!B;0efsF9##x+A8nklvQGcoMkNE62QctP8xOCNfEEpQRl zCG8P(FrLD*ftknp#xN$61R?;@OANa_3%{t6HNZ}cE}<@ybkul^L;-rrQrl3<@{04T zOOWM*B-QN7qvH1f%vztwh!k`|J;@TOMDx8!9Z7l8n0_y(4Hrup8+Gkk&WyuOHE%TKRKJ5DnGgKIM>I#DpO znw%OcBK{O^d|p~TnD|@+0Kx>AS{Gf|u{Ubj8`A9!TlRh|)&XLzTrEGdN*8QDqa400 z%%#p*;+rxI_RsLhy@J^WfBKV27d_*Qt&7iC!XIf*#xypa#ex=k>gqh4QaQYtOwPEu zsca=Px0<4&y+JdVHi@L|NoE}5+)=QFvT>-j(#9GI<_K}zgI5oG#o8?K9c#=EM!O6k z%O9?p`~lc6#4**H`IOJM+y=4FhkcU9A8699W%tvEwtkojfxNV2hR5$?7EIP0$J=^m zXMKxpeXA!<_(U90folY*^0@`CXXP~ZRKutMHc<4#u!dx1W>e&&t)NKt7yl3rEqyaG z-WSIyPF$$`2z}F04T7{TC6I?QnB=h_c3_134UMBYKr!TD$$fg?Q~P8x`Qj51OMBEZ zdu>;A`~L9u{n72C;q9Yuj7PRlBQ*5D?Ad{JOZmFV9yOJOO(hqbe`oe;-PaZ(rjA%% z1BAn_9}IbB!#Q&|jXI(uV9+nl1MS`3yOUq5YLAb^)G?`Gj9|)wq7Ke4qonb{nS3Nm z)l7R5JeqX0j-z8uih^iOriFo_p(F)STyFwiYn&5V8&_k{#E(sNV@ar9F-Q}~eY8@4 z8-)}9gq$zHiT4w15idX@R{To@rFK+4EKI5J!Sm^QYI94n zGWi1{-;f}Ii1Z_!2*m(3*4rB_h}w*7Ib(7zA|M%y7ACi6BC2>7TJ!IzMQact-lE@W ziS9le-hDW9WGb?II=W*zY`HI_yDz+BI!+Xp)}y&<4ZmruCrjcdS6Aj>Y;zU~s1Few ztPOU+Bey{kxtC$AD&!svtFz;p7kE1LV|?Q-6fcdO6ImEJ|3qbXAQQHr>9V7`!mzIJ zZC!DkXe%t{n_!1LZfo(VY#j)K{exuJ6kn_(!uXiX+lGmCjTk{vQucw#$-}CR(zqeg z)#a2L8k8FT8|o_8u#zp@bkUTb1EH2=G1$qmZ-!C*h{4Yj5tk(5kzu>In1$5Yg9WHS z@h5(j*226trYWLQx!99w3n{27uJIC?N3A2-evOk$wt@%MbgQC%wI!aKx0ei-2-$Si zm0yb*Xkq_YlZnr$Y)?O-hD0~9XX?%+R&BOnWEfd1+UZ&?zb>uJIT^}~&5>J|u-p6} zR3e8`Ln_g{5tfeC6l`TlA0$xY;wNQYsiyw!uK-7q)|3#9j<9wR{uYE=vdV1?Z0 zgwDza0Gz6gUYAG+1k=+7BmS$)aZCL^ah0~a%3TD> zsXG0yd@bNye&vslD6}h_3u;;B0+SH%Fu`yz*taJFNYgww1Hl+SWCO&pRTmQMU0n(I z14}TR)_y49pGW!RuPGUTNdn>pHrZF{z-OQ@|LG!BGw{2+%5v%-X!%R%mg#k>;pPat{Wu znHnH51|4c zU+m?9=nqp97#0Kb=pM<4+c5lOsIHp8(SIT+I&pP&1-DPQV8_|zRl80cKx|11fhUF9v8I}7xSndok zNSjI!y2?J;$+Rpnd1!da7Or6)nC_DB$ogaAaU}U~_zU*Kq4jgapwr*WziZn0j;Roe z92+Ks{%*~?rem=!-Cxo^o%K}KTc-M$tvYIJ3fr0@ww9==C1h$@H`$)HJY@;x54>d> z+_2j9hml0@x+OQ1R~Ks7bKMuU41_HM?^=i7sqYAF?Tc=m3U8f?)K7n@|LNhUhTpQ* ztXp%R9(@YN%Dit|XEt<(?1Fc5?p@D=O|;gl^Ve3Rd3(cod*5l>7isJLYX61d^TTiD zH?QZHl4?T8IrMh^e(1O4_k8qW8?#8wqYhLi+f%ks(Qs(;_&M8Krh7KD6#lO_tz1^? zM?bRUf57pmoC+5Rtv!Cu7BM|RaCd8d3UT%P3czF+ypxlUuh0B);#BI*Kz+ zaSro83qFS)c#YoMOH!SMsNx*L26`OA8X2TQn@w-d=xh0u z=xYT$g%oNjq@;3ixAwJNELIm!{@oNSGZpg&imp&G346;)j3af817Q3CJ-{h+f*u2wN*Qw4Al<9c#r$>++yQL}hSyy);1MZz5gKd-bFet|Q%?SwrNyM8x^D|9g~12Cbq`;oZuK;U&Z|1kHg-pMH! ziO$P+rf4MHOknQx7~AiIC#Z3CNc>{TfmYTY7+Z$8K}(X|IE()-fTx6WMlUh?AQ;~*_XPi7VKPvZ|F z@_|{HOCYx79)v^t+`qbnvf&~$x;l$4G_f-6>VElh7c3H{SzOdMi5NfrA9&V^hyARG zPPnT+>J%qa(sr(uI^kbfX~f1LutNI++8FreoTq&&YdU9Jk8@44+sR}v*0PmESXAV% zqkCPL*7}%~4JB`XP__>}JV(0VxLOLVAZijYZ;h+|Q-1Jh)^ZX}#Ri1<<7d!;;x;%q zpCRPPI3QvG0g_=3gz&;5xFQ4e;Ub)c-z9L7z;mTaqGi3y3$7p-N=x3L3)?1K;^m3I ziuV(&howY=4_9aDv6(}k&^ZvP_2YV%AYET3We<-X?L9a=;DPHNlq^HTg9isZY~;ae z1}<5XtwDc>;^|o<%FIT#I`5@y7~dwJ_%@y$9UY#&-T_eKN8E$LWRpWAZm{SkCs8CD zE0SVI1IJBxhjK??*2BsuWi$1M)g=#uLj&Vv3hcGGa=k^C+=1=|R6EQpklno@DIb_Q z3!9>U5!bq>$s!pk4{ReuFO{1bn(?m~<(}-wr(Lz5Vu*{sfJEA8P=5~|nUJs4kMln; z9f_5cowLRA3NP$Fzx%?TbN%bZ72%4}t9f59dZFlA!FA1BP2G`-(Ma*=x#3uO)#d${ z_Foyg+7~H@!ujrS`R?nTk@CKChhi1GuOAAPkDNR7eS7(pl1O!Tta3}VvNK%S`HUr2 z(Qrk)bl)@PnDegdrPr;Yf%`+1p7WOXs<%h0cZI8WUC)YC_dR3#eqME~eQ!9w^_kHt zW$)FrMr-a0*WC5YC~1$Lx^(LL_7``3Yu8IV->7-3`%t8L?9J{&*Sx>K{pv&ir8C@p zDBL_2sT_Z1f2_Xs%J9_**tIr(!+2eP{fS7&c&PnwsOAU~vX(}z)nRM()$VA^o^Z>a zmrjLiCZbm1W;K`Z+^ErIHO5Mum&-4e|2j_n8qW>HDylCZzjXXcAX3p8t?*v2dvVLR zwuJhH$nHtJhK_sBjX_*GryN27vX*_Uvg3MxNbrXIk8W_>8Ge`~as2!U$>kJ`YHsSa zIZ#a1W|=>}<=|Sz`I{y#f6GTdvKM0`fy^r!esK(>T2|vvp@mO(=kVyHb$i)W?X{CH zY5&Z8!~BXhwDVBdKK7=G0P*!_K@r3DNq&DZ|M`B$kc)ezpkS!L{7Q2#kC#_l^M=Z~ zSNHKlrMlO+PWWHztVB32xIu*13-shKF_T;8)?yjWKyYOe7$xySVtoqPSk`S_`t(B+zaFHECKEgM zxS$ZCAXW``Dl*Bdv#7U=y)xjeJbn5h75lSTAvt5@g+%C7WzD2pO}BSH%NJg2BCD z5dFZgQjW@~jB`}PuOL|bDmf}JGzhbm1(Gqk#B=nW5@42ZB<)Gxqp~@g+@xxY@VFQv%0VdYcWefviZXbDIe6z+tpDjFRiiI)gB^0TmM+@=P7sABo?`n zq~nOIR8x})($+4%7Rk#{fd}PM*hh&@`z<7QgccqWnXI&gL@X3LA(D#JDiB3_#e*(yV}-ME~fxH($f9WL&UnJRHj zTCAu(mRk}x1ryG#eXpb@R@@RRs)$wXgz#3bdBeh$?AhQfC6Lpw=j^M&tz{bcU#{sb>F?3L(oHX~ z?B&VdqlJI1K-SS~PaEqzBf?VfGGSeYmlNc6f^~CT-Mppav4l98NanmDK|$u>~z!M0N~Df=?)x8#BHRYa8+ z@T9X!o-N_T=16-4t+I?HE#}+UV#Q^Y7f|n0O@fzqe4C9>%at9?=O^2%`M!TTIbl{WrBt&*H zq-vQ$$>c{+4a56*aHk9~Q?76#Sh*N4VhH?*3XmHr2?TPZ4>YTBNJ?f^7;s zSouj98VA<_Tp@P%YgVdRO7xkfoy!yld>}0m5JHyU*b7Y&`8VyZ)Vx&?L0s>gcNx>t ziF1@mCuy-eCwqql=V+f3;;ZZcjSlx7#--Tpvx^H@uW_uHI9Y6!54Pq-?}3rMrZn2EO8lm$>!b))IB`x;gK8>yOmVl=Nsf1)k9nj*;XTq_n$Eu#@3pYlF0?Z zjm)%#NAYM9wliTRr+gVJQ9!?nI82sttt7G3yJ)Iqy<3tjIk}kOfK_xk;@>A1@zejnpowgB$qXRAR_bKQ)RflRK*?N^5 z9S7MhuZQUi6S-hi1CbF;CV8965@V4J@#Mctz<*B8_sF5$HDjA7Ti7_=5JzQlyLjR? z7XJtZc^or6NC-yWux9H=acndc%gMiBI&ZpI6v?TM<~(p^^7-S>9lv@aQoj>#*YjU2 z{#J3Q@1984y?71X|G+u@JC2I4RKl*2CHI-mi+4pWbzuwcI<3CE_t*B`LX>YXo2ggp&#WsL)8Z(xraj5L$SIo*M`FO z?vSbb;}0y99a2IXKP+&4^dXG6V(X_tb=7-QyRPqlRd^%smE)oAv562Z<|Jbx}m42KMR7FMrTbX{_}m7{x0riyS2aF_Hvbh-1;mEaq;~- zbuYI!!~aSN-@i-uN@+g%T@>q;b|d+B@#J=y2ld)lwY7ts_SFV{pvmxRV;=c;^Az5# z2mEWC9{$(#TKH2|LI=vhh>#j}ZNZicf$0xn^o&A}I9-6~F}8aNIf_hY3j%HC=#KCV zSU!{Hp)BTzJc$ZPdk_kqWcovEfXYipy#fQ9l-HDeZ33j?bhLGJ83aq_O(Sh7M@=Kp z3B*ckz-^Qh%KC*1gp8Zy!P-Q%g`i{DdiW6RoK^+w>@pXIBbeZ;JX1RWs$K`+4c$y{x0Iw9~yizhcqZYP3bY#Cq^wD z>s82GDkZt3!mz8XGwkogXOTuzw!ESH0#Xq#QKG*_j(UtJev@9W!;u}hk?*RBbA>cB22m!0Rwol%i zHQaG?R#JJn>QYr~U^qH(@0$bnMx0Y{uRrvB`E%vhItjS@r~UU_+QrZeJJDqOergq;f(H+X8vfv8QUui5jEly>1z$}_fMyTBE)WwS(K=Mhf zEXm~~{c3zke*{@K8D~;V$`3Mzf&q{j@Mt>S-p&ao7>d`A*TiFH2?m6jNeT}sWWc%U zDG&>;ix;e22Amz!+QOdW1q#O8d^6jW9cM{tr1FV4dKipjhq(CXAkAedXB>r9 z#^RYrnn|FHO4U@ zfk&D2lL^1-IN^7DanxoYU6gMS4*D|U5)S$YT_9$(M2#h3W68z)BgU3>8+hZP9Y@2q zVGcfWoKNo zr;qE);$PPAeMa5OT0Q(f$4q7%HYnw#%w%G%F?#9O;7Om!Y!>VAA?-<;#j4bWB1MW> zOtQGxGh>Ejpp7v9jF^+UDZ(M_IiCM1W|K)_@X9?+Qv@tLI5)_7g`8L6U|@Wg{A@U| zVNByn{Bv?>k%9PHrh&u846z*8nE9k3qco)WJ*4nRl#PVuf|F zY?!YtBu*lZ!!SOWsU}=9$K0e9GYQ$qt(du2R3RIoz@-@HD95%XR~Zr5wt)3TH>c7#O%hg8#kGV& z7q($w80q2+BlerdFe+Ll6e2JSjZUyeAqnAROHRP$Jow7*r3v{d0@z7X+O13ohNNF) zUXfSk6)_QPCOrrg-2#G73uv^@D*?uh2@3**22ZdmikFTQZ- z{GluRu8oCi`Xl)RQQN>d?f31uN($k;`$N|I$@a*x^T#euM+%yw1szvs&c%B4E5>e8V2Oo7pefb!pd?-Nbz= z+i?vR+PcD}UDsP9rTf<#nyzmD`mPstUE3XL>4~-syi^ly9u7ATheoC&&G$u{XG7lE zNW&c2K%q4DM#{EE%XVFxh*dSwea%-FBURnes(Y`mz4-XI9)IIhWZ%88gFu1}u&8`1 zi?a{%%E1|r=8RRhzk~}E?s;nt3V5d?bBkg7(%Yt`pOXB7AABl0YsesGWOMs*JPw^HzpZ39_&d99mXUfTncBiw8I%tN)>*R$+HmD<;fdGeR& z@%8meJ^5?3@IygaA$8kC?>uz%hy|On6ccgEXfC2OGcCn**08w_9%((gHL&d@+hEHA zI#X#hpfm76{NV6|`>oPuZuR7ZE&yi83^2V|Bn9@HhRmQmk8w(Dh+J9CRjH5%6`B

{NBcQL64&#lmdou>r0hre3|vf% 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 F^bf{0_niO$ literal 0 HcmV?d00001 diff --git a/app/__pycache__/state.cpython-313.pyc b/app/__pycache__/state.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d757da9a29e5e2cf3d6787a884ecf888894af077 GIT binary patch literal 1715 zcmZ`(O=}xR7~a*6J}p_671@&Q_)B$d5htQ`DJhhINhmZSPGNoM!DLwPj+C)iv+B&q ziF);+r>U6`)?{1$}*Q8ddjWf{(#P`q(y;vL91up=Xt+}y)6`SntZ-E+&=rw z(6oQlVfv&e;C4@fUo}%R^%vT6o$AjMG_k2wwQbEztZ1fjk}!dp{02~?oob{%D7QvJ z7>A@Uvd8@{a-LzE+mmR~ADA3&|I}6J6OHPoMiZt^jUDX-oFvV}u9QT`h!KcUGD0cI zr@u-hl`?6jVm2M4OpLNI%0=j7-?^C0$EXmY%*l*$l4<@E5sG)J7w%+A5u>_Yj8S=_ z29sR(?)0ui%uIDM&eEOg<%m(asu9XcREupZFA5rr3Qk6a+iEUpaas4og*U6*<- z>>`ovF+?Fp@3<&3i(rm_Mh?WJ+v6fhy&gv*g^5hV#e8&x4zjTdFH#m^ZxI#w7ABSp zC#Uoq%(zGUA}v!#am($VMICBkH}++saMp9$2vWp)E@vV~WCf6^&P2-bIu<4(+p)Wl zd!Eb0oPtwrk@dQWUSQ@?*mW%;7H14oQQopSzCbYQR-{i|&ptEDIWjmSnO3qjq2ehpXt~nO=|ijAbun`O z(2g2;k%5p{9R#7sLD=z}o~v*k!k>DUJ7J0twlHPf#RL&g(gx#HNUKGNCx<8dVbTg5 z`DLK;!=nk{%5?~ED#s`cxkyog(U`FfWnUDZ$_+~w`neR}%AY2Vn;pWNPV=St{pJtU zJ3}^S&GDsaQCZ+ykDf!%#?4d9KKEKJY$G_nWgI&mj5ST!T{GI~qi&z3Wa#Ln?k!uF z-=|-+u()`66wa3}Uxo|i%U9u2EdaH!yzCpk>1VE<1Ym0{sZ;|{4d>=BpNIM4<%{rM z)i3+DU%fgC!1ma*SP4KSER+VH4VeFQ@b2o-sIa91NK<{l27B*c2Vga<-S?g0!HqF| zd`(8RhXHt~@&Vr**8Lx^=SSt;0PK#di82+-gQxHNDtoCiaQuUz;Xl42qtZ?QcEU={ zulvm3yON>nVRg+v9@d9!xOe?}RNW82emE^kHn_YJfR(Ve7J#*|x~fXqtShgL`taFJ z`NqDf{(dytI0(Q&xclHQ@PK|f9oTmbT_m~Ou6xt~BE|b%Oxn?MshZ_@vTA)}mjj7J o=G*ZFdP2XJM>%YbEy>rvhOX;>Yg_-N*7f~CZMbu*Nf7z{4~}W>X#fBK literal 0 HcmV?d00001 diff --git a/app/agents/README.md b/app/agents/README.md new file mode 100644 index 0000000..2917425 --- /dev/null +++ b/app/agents/README.md @@ -0,0 +1,15 @@ +# Agentes do Template Backend Enterprise + +Os arquivos desta pasta preservam a estrutura real esperada pelo workflow, mas +não executam lógica de negócio pronta. + +Cada agente mostra: + +- como emitir IC; +- como emitir NOC; +- como emitir GRL; +- como coletar MCP via `_collect_tool_context()`; +- como recuperar RAG via `_retrieve_rag_context()`; +- onde chamar LLM/cache. + +A implementação original do exemplo está comentada no fim de cada arquivo. diff --git a/app/agents/__pycache__/backoffice_agent.cpython-313.pyc b/app/agents/__pycache__/backoffice_agent.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d3745fa24facef616d529dc2299a7b446654d3ba GIT binary patch literal 20700 zcmcJ1Yj7J^c4p&EJV<~9Ns!<}Es9Tx0;vb}pdL0wQ8F!&lG%hFmSq?MKnfBFP~Cte z(w>aRQ%R`Jq$DNrimutja*`@jGc{{X%~XxHHdV1rkH`6uU0^UXz;rxbRb}#Hs`7(+ zB#M*N)_&(U8UQIkp55BC#EaYao_p@O?{m&Qryo^TS~xsghT1NKU*NdErx*1xC=t!0 z6NtRc37nug!o8>wH9W<&N3<{ML>-Ikj_6-Bhz1tdA2GgY5=|^_I8yPVSv2!p1J}k0 zMmHyzUNH~o6uhEkx}wMY8+z%{$X0(e8jJhm;aF6{t8OSdhab0pb~ZBS^G}DOao?;M zn+iul^gA;fr`*uhxabeWec>ST!|^%a6taAABqO(ah)PW)kTVk+3ttUK14_k8m~Y*E zyuZ!8!igGz6Saay)ODbBqF&I6hJH>o3i?+}f}smgeQ3i=8iXbmRNEh&foyDzpgIEM^m0g&G#C6xIn&7OQIIp8hBBLM_X+ z3+shC7IOe!J&RQfZGwx%YJ?3!1B*EkYhJ|1SmFEAwIhN7co|1`OXYKE&8Wo{H(bAgIO^g>*b@dV2F=|ru{$=OYkxAQX(V) z_wxHxYSu6MBcX_&_s1*+^!yMXx*7^36c{Nqoe1#+8x*NbA{w5Gi8G-=i)D+4KkAQ8 z9~S+y=T}k4Px4aYlivDl})Q5PqOY{f*y}THUhtQB{JjSD;=R<*uv$1eA?y+q3 z@FQrv2*Rb%L@a?QAC6uK#r^n+qX3mfDvHenW_|Hk44D1YhIm+{DoziL9rakYdHAQM zqtYHA8XG>&Pof~N;P2%ph7R}g0YB*ILg$q6z*LX% z7na{cmx?p~NO+#QG{jGbM06hjrP-Ji_j@b@9{wZ-X(|%CvbsD~AqnUKC55H<@^_+vFpyg&LKtCWNVB29^7k;s@B%u5 zv6;h{XM(54{@k&-erWnA}`Q4Mf7IF61jzAlDUgr7#8}0&G%Dt}i5qXM*Q^ zxstNGkdFQd1gy{iRg>OViyy$ ztMagoW9!Et5ZNJ7ZZ0avrg2mXDK*Ap;(TpkbR&SLaXx!ib{!l!G~_)x>6<)u?5N;7 zaN0LIJ~=WzIUcA$)6@q$9)}}H^XL>JZ*!*{5jBT9#YZ&i%c=edL9*x6^AVKD#g9ky z`S@#*+%dv&r)DEYHGLW<+IWvi)>CqC`Qu{jLMRZI`q=J_V=}jC`(lA`Ur|qEuA$0!I2bFy^kGdZ<)mUzhRHKKCs+7< z;b=JS^T`bd@~e<-b{;in4-rQv~+4S+WiMw<;0* zVdW!$a)EsJDLur)F52#v_}dN(;qzgV|`J3O+{2=Lv_%Vm0DM_23QlLfMcLR3h&&3g2Ux`T9gS=*z$&C}*_GIco*#yI zz}V4%Kf3(GXgCn#FM}fmuLACk2+vm!Uc-X|a@9;&LjBXesc zmL2ifESTwBMmQ*`6z@c?NcGJ(l_M7{k4oD1`JIPCA~7OS>PC`*z~+QlhXs+qx}S+< z;5}DObJ?g!Uf`^Vor(g5tfweAV47FrZX(mdG8x^nf#Op6sjXxyvw8cr`O%4-+(hz9 zFfm_d=_%qP6GZ(no@C&s#4DLo=eo@wm9BvG4V?FjxtYGcTrbfAi(sk-WgYDU>kE@m z8Ks<%jj>eW^rPd4j@{A9rWq_|phbVo&4R0Btfp+vOQvAe4XjqyN8$+e);wT&S8d6FtvasCf zQ&+hdx|9fup(|*Hyw)04L)T7g_KHs z=jOnJk~9vMI~0{2KB#^mtUzwdwaTo}8I1*GtFNeqdYU~w6(TXGvO#IJBj1pq+7Cqp zh)1Z?UL<&pD87K-RcyCNN4%^P6H(b&7`Gu(MR|~Tkl-I-9sP!Q;i{VJGuLLawz{mX z`o8;Uw!J&sx%+`hUs<(c;aodbsyTb}3TL)ko^WQf1u+EmTr;0(+L3D7aW|1}+Lr_i zU4*9jzWY$Ntuxd1e5&pF1L~TFHNfTML6ox!b?=O?u{9@C*}4m zT}``pecrnM_NH{J=a+{k(v8B$uYb7y-HvxUZd=pt-i&+SUCoaS-#6Ty`a$JS-TOXz zJ=G{YFdNWL8x4(<4UL6{#zI3wK?B#&oN@K0T-1-B9ZkEYGSyQH#`~*j2z}0PxP9pT zqf6(~{FdUu87w(CwVd5~J$fyQDU!7{XB+l>cp_yxnyufxV&?2EE1ZEyFc1k8I5<~J z*4>(MZ%ny2CO7R#yGJ2mWt+S2H@3Xn_)g>M_GcS=GL4&4jhmOI(~Ua{Zx_>zgPF$X z@0~+~*H=BTS{*=dHCrC~wU~kr8R~Y8kI_febyV}^LkH)c)I6}GJ03J} zHPf00dH_GD1#MqSe~roimV0!Q=D%o0ZTdgmhuBAM&8S`fQPVcWKd#b@*62UBH&DDw zGg_zrxO=D$FU$KhqYe7y{gm-Zy=JsY|B0&~@gJMEj<(kPc()btPxYG74f;BPk^*b{}X#N#W!h2d-Ol)9a@ir^HIKf4*a(4CQ(7P`$2tnysP;@=3@uYySFSX~B-BbzR0mc#$L1?3N?3}T zQ+z2%K}+(i%3MeFUfo(Y*Q<{j&g8|`fm}6FV;=`W0)o&BNQzc?EAj#kwzx4Hv?9N^ z2UG^>4Z;8fo>C%dh1wE`M5aMI;nMQ1C)^q9n#C|gh5r3iT*Cj&G* zvd$k2;=rAq4Ml_ENdh#p6G$YLl)}C^z^F}yrxSEuGWJBS42GusIEj^4c5IVo2*;f% z7z#pj8x)zIg?d<30`crP633|K&b7x;=>z-E0W1A20-R6P^|u;tHZJZ;SGQ-X4}MH0(+>?7CN<89JL9I-7j;YHDaM z-7vpUiG4of*m8Sp!SL9~xm&(nx1i6~)GnC*uC{UE;Qz8a7vhWhpEq=89ksVAZdNST z{z+BV;ks3IvuZK^3rG6{6IbV6u^1heY?X6Ghu`~k?pvpCo?g6?uItLwZC~2{!R|Y| zv(5awqwkDn>zlKU{6j0iRyd>CB0@Z0tx=){!xrybn4JIji?^SHj!0W-i3xd4PSNDU zw87*ny+M?M2Te4^qq?FELU$&=$knB)E;m1KrCX3MgRMZX z))9OUb(DrLwcM_;7MF(4>rjg5x%3QpV~OQ_`Wf)167WOMfX{C)MfCJM1KwN$|Finj zqQV#Vrypb7$F*~Uu8%et>$q%E#W+WiT&@Uqc4BYou9KAH7ei1bLo|s)RE^;BQ#PwF zG1aUy)sleE<7B(qf<67OBgabIO8bP z)Oymp_ZSMBm8=WIugcnUV5^HLl;}8NdyumKjN?`Ad20VDCm7rVxb(jf5R2@*9=;Y{ zG^eZDGF9ET&n@V(m5%EtuboWRb)_r2GnL1dMm`w5Gn(A-Lb`Vpub)jUXg}Y+|DO2C z_RRJdQrlm+A>O)t^K!Da?`N+5&w`oyOUaWjXHLGFI{9kSH=90rX~Fu1-F@HTyk)v+ zTC7YvI-hXH>Tc+%Z|%Ifb8%1F+4V$+g!{RK{b}dMCk7;}7`a*(!6chJX=mRP6Qxz; z(iWdfJ2yQsQ<5bQwkhrGePX3Fn+itgSL|G6!#~$>_3IbJWX0}e*REveZbY8mvuJDo zCNUg;hMg;?1uwm)ze|B8gJ7q_+YkLk8TuTc zuNv%8sXnPu_ZPtl>TAnT{|n$Y2z9I;i0@_UsYgAo->RO5()AqHjG=!~Q{z|EvK=sS6aiN|gD~FuckaTy@ ziIEKi_VVTT{d^2J@;O~2R6N?)MR5=qWs`I<3??%u62qQTy!G!&Y{T1xinyrg+DarS zDvPM<(JJaSLOowrc}2{shw2r#0B%1`SFwgl5U7T-ae6{CX{(3R5lV+IqV`s}czOCQZ49HrDybsT1G+ZBD8dOp$^H8L<`=A6w{XC0g=d1B1|f` z@_nF;P)^hkUjri9&SV6iUz}#zTS>Ms<-K$z96v7x03jYFuqqfXL?S*_JD5|06@(B) z#xUg+fK0lO%>|@TPBYD51#q#$9)WV+PZ0L*r_TE_I=jyREj3{bnNF;f%;BiL;bpA; z>*=~pnYt}YV%o8F!I16VxM2D8Z>x*w>?7b;_ z@7>csI{W>zAMVQxyp$Sv>AoUYWZmns>pQaRHe_3;q3Pgj9&?6jC+T;ZZq3}B$=0@H zYa6rcCX<2bRPFg}>kw2fu68I|Ts2U+Kn+I00~=S}c)w{~rpc3P@+`$aG^CpjW$F$s z9Ln09*T=7oFYfr6tqtTPT?1+RK(^jZxNnaxC6Yt0CNEyfTuh`cCX$!u(-&V)?RaB_ z<3_b%?Tr~kTo<%YIIjAF_8|+i)pf~6Azdx3!tru)dL}b{DK&j5DPB%bUrFt_ilQUh z=^T#p499uxBj7l%WjKzctB9Iqv3q|D5f)XbIS)i=^JUrX)y9pE@p2FHV)B+U(S4%b4}!!GJZ2UmYs z^T0^WeV`{&DU9U8FC__+!89yFqLVVewIjl1-R}X3N@`+P(*kJsm zb{pb9Hfau7)8d zM@eQO&p;KG5lGl617Aw`!g*U3KF`(^;ordEOZQW$HlU|@Fm**36y_CLs4`TR65*cJ zbEV)}9T**ZnL0{|eb1_+6ueqTwXo4^_d2}QUfp)RV1cn_#RWYWWFrh^t%8kMP{9t< zj%uN1fHqh>QBy$~a3;@c7Yq_qCXn=SB}d+=LKO9@gY`7k>ICChC)5irm|QdpZlP%) z$5KblUQ?dg?g4vM8oRXwc10*I4PU9kLxV{5H^VTtRamDQEJQ6Vm50e|TV8;$;@V$7 zSzWSq8&rHntz*57EBbbiE@~^Z!r#nsMLn3Gv0j^CrJJBKd6kssHLHE$uUZ9}A8lZ9 z5~R=py(n(Rqm`w5%$x546(iG=HWgz%^@lXjYRgXjE#V#O%`u@CwcHv?^6Caq(#&YK z<{?+NJU?cDZ$v!3y-MR_Gao_LGz% z&^1?``bVLg)oFujWYf5K0kp|>*dvGI;d!V;!%-Ng%4V?1WU2@=aF`=;;xIw!nU#P6 zCdJ~Mc#%@77$2EuT!;b6KcvG4Kc;!agHzqFC$ln)+V5l3pe}=oPcvy?VhmW=Nkgf5 zS8OzP?3Ka_oqU$M4m>3=>++aO?u@H2_W@p?hu424B`&}l89dhDWJyuF7(e4K@@(2~mW2#EFoSIk_Tql`M4UcOa4e0Kvb!$^BhJ%ME?D zY26K7*6D(V+PNX?ZhN=)o!;AV+}_Ux%fch7zILe|~0OGX>~9vTl1jM<5d8XF3Zp37C^PUV6P^&0ssNMA6W~*8G>mC z^KZzq_yf!gg4BBzehC*46thY&6;QQ_blY$lM%~Q+f?=?z*#LDg!wlFXz?N8X(9ntB zqUO{wSp^2USdX~iNGQ4DUm_1TBX3i)|1$-*2)IBwvrTa1%jRkl-yqPhQGiW~5L~BX zIv^%Hn0P{rio}f}<0S&3l`Ae$@D`=kC`w71^2$vOb`wsd*&XvoCFepS{t4y&DFyElh!GmpnOIb|sQ}bF3YGcEF5U#BgdKvW_-pu6pXZ-{UJ;1C zL8#V!#bp0S!t!U>0ROF|-V^#pN8RgWvKhfd+e(wEB+P?0I0SS4mi%n^J#}gAJ zRd9~lC%EdSdwxgFN+oBm$Hj70^;fA~b2`&x6Dc|~&X?{Qeq{c>nQr6zkKpw)aiQXg zzS7+AkVEjG6=GJ_)|j=qvbL72&7HNa%i56AjFd;!W|w8b`nZ;B-StQcFQ!|L zBrDt?w_#((wK?V5ycA8l_CY>s5VarGei%)jIFmdZNQPp`OLGuB-q4I{nf&3UpOe}t z`Z=$?L^8|)ji_C)LF=2Pd;b2EyZ>%W+WlO{9Z3#*GsCA-!>5xkUrY~27LNSV)wDRi zG4e?diUbKW&UW-<`R?~^@7a=@pG))4XZS-O9)Phz zjb*|3&lb++`n7LI1^>ofL_l)dA2BxB!}wr~5q3pe`vQk#yXyN=#4rg0@^;95pC|7_%J z4Qmj5VXa?VX;1IcnRL%?s%4EZUw`h}bBlX3*6x(GTirarmUa&;9Qnf8dV5E5 z^Jsd*XtuTOhBe#X$0*O*>lP{%wRE2U#H0luq^PCe#N>UJady#(WAGZ(^WR?wUI|9q zB~7yP(n=LBO1A0Ri#U{1j#B&xsk-PrZZ3+S|#DP>c+AHYrK^t!xe)T1Zq0%HOBBBx@ej+@dj#cT6;;$;*+vu3wF%O8hd8B?0VF)Lbd9fLMCZ<*Q)i$9nkO3b@R+40GD zi7XHcR;}~eUOoYdne*_YQRa0Eg;T<8Nj=jTK& zQR=zkne)#?JTTUsga6A+k5<-;u}CO)CkVqfJ)QIk(i)D1aDO+AqQIMw63g$wr*6<9 zUPG4npC}++c)s6a8HEZNE+C-H58o`@6`qGDZ&KXZ_A$GL zdFI=dvlmrtG*#O;H0o(g^X(SPuxbVjtJ6|OrGu9Fnlq$XCKd(fZ;ZU%&dyg`EMv^J zknkd9&?46rMo=-nWma@3T}WpsXL+)d9GLGS-?%Bb_JxA*%~MS!&*hcIiqdzGpVtoZ zvgy#!=uz*)hyT>Yy15nmqfLowQn;*2N%24I}QI*Tm8gN3FV;4g{ z6jYqda63D4H4vI5#!2*|pm>l07kF}x*T5|Z_kD;(so*88m+-VMJKUaCm9Zb`Omz1wxK>*CC|K;Z1yKF##4?&*1Z!NWaoP5 zjmc&bwuBETpxfYvjI%rC>|Ux%JNN$Fw)fvw?5N=1B^s!=4h$aLulaai{qQ<&*{K@ZM#j}Gxw zW^5#z;dszIdKqK=A8rW)7J-LYx!SU&lsGGtPz?G{Qt2WkMM=TAaG$*P;Oytlkl4T; z^*|}W_`#9<7TEJFNueYuZpdEeY3%B8u9%Q1Y78=jhQG6F(#(WJ#jS#wYCzh{Nr^d8 zN7kRiMVOcX+2UmcNG9Ega#3>yFj}wyNugt{uAZT-vtb38$^x^SQ&lxb@u~@9cnKZIdU{wEb?| z4})pPo+mnF{yLwzG2OH|)3ohw%?~Hjj=@JJJ=tmL%h_ptlV&IQu^e+r%p!UCfsFw3 z)6TJD-%6)!?@ZDCC6P@;47i}qx9jf za2BRQ%mJGY6_^-$EQjRQLeCroU4i&v@`D~Bk9scySvCzCf=c&-Tcgzux2n}6?G8rgK>Pp6(En( zr&cNV7l_LpS6@*=r1`uKb+F^B1`3<|4ZE^U{s#tKeboblY8m(?TR3l-t(pJ|MQ+X^_LBk;kxtW1+N zeo~lqA};#b#}+VmX@vYR8O2az_kbfllrD*xm^5hF%pb@(a^<+MB5t*qz4ZX{tDSB} zFky?ocR;09adnv1F)>PPEpyVD@J|bh8}lkR9V#{!npyq<9Kpc{tDjV0Un_uzIV>9r zJfu3xg>MPO&cQ9jA7`b}LVVO=oK^<7E4Ci<>^8oWpxp5z!ZcuUd|!h6I1*AXFB}~L zNAJgUhd&os@Z7o=nAbl0wu2DDCkFidRNfPbIU?m6DDpIIS;2&)eCz-&Vmx`Y0eKMg z!6F0BFl>pl{Q?P>!@(RVGufDmh4GaMg|r~Pm4FXHu!bOIDvS?S6f8V|IY%FH#|jCk z{2~Mj#U~znTF}pvf|MqPvUQNwD#ShvftG-eglA5XD3aSBR*59&kPw-Ss9*Jf7BG|( zLUN)G`&z^V+nG=w$RZhMm5mAdI)P6S;7btr7(iSMEx&g;6!BPg;3F3xT>0h%BrS3I zJwE3G2;W6)Zcl$@V)f@ZB>(dFP-RH{(!}byi*LHXLq`nH_Gs8HBGG!y9e4K-m0Jr- zEw@FiVv^sY4S{4!aI;#6L;r;543=+V+izP*z9oP0t139ywGNK2G8Vd}E`1?ZgG(yS-Q@Wq_y>7z@R zvHw{53{jldPmyqkQHA9}kB?&hD??)<1}p(5#g<(DJJ)2`shXFk+qPMuAig15lA z%&9=SVb7xGUBf$u#fxcZdmHvF`9BEV3Ee&P-I<>@?D^=-f-UI^uIM$DuAKCEyCLn` zn5^FfF|W5R+qN;&wsR?*T)zwNyF0QSTQVK{@19P!KZp0{HRm-e@UTOugI~ax4-H)H zS0q*dVsNQ;T$4#Us!5V7c`yfCg4Yh zO=}AGgM!x0wiK0zPz8lm@VaSzk6~PHIXN^sIXZsWSLFIUdJtbW89g*QGO>zpA?mgf zyL&83gVH~p0W0%tUXH|8Eb#a+Mji9{oGoWsv5Y{l&%`cSO-$pfvj^|*51ZW71-vr zqoBxB&`H4t3OXoQPeB_3co?w;_sSyKI;~ zLW`FFk{q&Hkt+63@CpTE6wss)q1)!fZ%{y57Lf#I@qbc4yo5-*pvWdgosW3|hXX!; z9Nfn_2s9G#{tp7!L;(X0j*M5GT8{66`m%ji>+yW@52gn zhvi|dzRU8^VnATi_gEfmZW=Hq^-al^EswY+gYog8$*}%$%_|y%;_h@h3lFk z?MfbS;`pQ&WihR&i?URmHoUai{VJ(G>6npgiV8w}qc8J`#AYeox4KK^ioMD_*e~Mh wj>GcfBw9DnFKRTu;@bX!tN#aX&#$=szvB8I8_yUu81{{$6`Mrq6c0dfPfg#Z8m literal 0 HcmV?d00001 diff --git a/app/agents/__pycache__/billing_agent.cpython-313.pyc b/app/agents/__pycache__/billing_agent.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9962052bd05cb4d8db875e83d5ddac4ecd66197e GIT binary patch literal 4042 zcmahMTTC3+_0G=hdtqUDnwN)%fi-5A5Mzv!*v3G%U|1-_TDVR-9d-v8JUc`0%o2lC zHHxK(O0KGWShm}bP_+A9+FRX})&aY#2S&~KaPOs}QTRlqt&u0`} zN{chnElEihh!mis_;DBkDD32%O$fU<7eF`X#-0^A z>@@>dBw3b}6^q9+21uBJrLte~q^C%qya+F%!Qmo+yJ)dWhIe5svNBb|2V`3+w0K!A z>Wt9hoJ=@aWAT@=eSTJcF3MtB)bXb2*Hu;Kv6|6EZ2GaV z!fPVlkdmV5ObW?W(e!Adu7f6;=~0)+oQT67lhH(Z8H42z4iHd5KqUb|0;&k82A~l& z+2Syh(X^sRQ_&f2lAXiqnwZqJXu&+oSV)WStN13bCZ*_-ki4lbFM}9-QdNMZE~Qky zv?dCME$q{x#ZxU;% zoO6Vk1P5M)JsZ?rI_?a>&IC1IGNZiUXtZ?I{XSsg4%yo75cCapx=iG%}S(=im9Z3?iaB|!shO)!<{O&{SLbto?I4RTdq z89=`+;j)A1|M-WSJDG3;6$i&I4sq=fM)4fu?;Q!M%tJh{wPU9(W0{tgZ*yJt%ncGm ztnt#z%KrU`f7n#GQ(&R)*IOvz=|Ub9qvCrOylugsS#aEg;@`saYk2C1>18V16W?l_ z9E)6;oSvSH$N16MM0|ng7Dne6CdSRsQbv;$QPUt&rPoyja*F9*7W51jw5=K|xJ8OF zDx&EECaK3;wiM!eL4&<@~L-jG8~m$s?tVmk8x=C7&Ay}z=157|tbwWJhT zQCk<2k|0ZPAgq{Xi^mmV=V3-wQUVJH#3p3{i(A4OR$W>Xb!kIN2`SjiYOq7gtcX~` ztg!d#-rq%yRrda@vYIMONl6z{f)-&Dn#jTiRb$~0*|GS=d&fy&Ecx8n!sNAy zFk`v|MSCAowNC*NcsM^@H2q+19$W69nKYdLR#TY|72I`fc4lsR0=x&G22Lgl(`KMR zichWzVB+Aapz-B|tYX0Sp@yUWMf_J5buk5{fQJ{;kZz@yEZB(@HjO+*cjrzU=FABCCAZO6`ALR7<00R&}wI}Y4d^~dhdakA? zU(>%km=6tRLxYb-9xvrWquXwywLRZDoNXO`?98=Z%r{)z4*bL4V1(-L^?%%NR1F$c zbw+j9Uy{3La^1uGRp*VSeqdC?0{NOsU{p~F7=Qrki{?*{XHSnmN#ssn&39eRdfK=B zhQA^2Z_fIg?@#9a-C2M45z7DU9?W;2%XXjJACBd^e`55!neQ3S_6+ZzpUm}486A;) z$6&T&@X_13j?2&8Om*nMhngb?)fRs*;qN8<38+W)ZC?@Q2MEnjt2ES0eG>f~p^7!y zsB1PtE%y_jTz_zVzvq0ebtK<9`#ACBt?WCq`z><^i25njYxG8UHUWL3*=S}z8F?_W zvzTj+>^DW>-$0wucKZG=06Wuew4d3jIPfyn(dV?QB6#5R*7~+R2eqiNCEpOqHbiza zkCZ2?xrW(%_3XCGXzSly-d)^pIk*4rwfz+hBnnMA;J$;RXNe%;a_MwIWI1FNSFR{9p zSe<}cR8_xS@ghlq3%saAjh9|9V51iSbZVM<&cJa!??s_Gf_i;FXukpP`miw;rT#$0 zTA4qNIspBwm5Q;Ed7at%xS%P;)|*+^o6busHs z+o-U+(4Sb13+Yb^J&V<@l=_&k zD{c*FQ?kQRnt+y1qvQvG)EKKNQ-x literal 0 HcmV?d00001 diff --git a/app/agents/__pycache__/orders_agent.cpython-313.pyc b/app/agents/__pycache__/orders_agent.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9244e2e5bb0df2f1ced50cce4b638e1806f1b1af GIT binary patch literal 3940 zcmai1U2NOd6}}Xyf620CTmEmkCE8X(k=iBY zCI*HT4N!PMhM^BZ-0h(mHek+x0eQ@0nzZPv1r-oMw8OCOC2vmLV(8n>r9?%^;`IVN zJiI^W+;hIebM8Ix`#lKSYq9>TZ+j8?j(k|nUS$r(fw_%1#8DT}JcTKi@U{!Kc^cC! zYDfKuqdO7Dyh~5el{J`Iw}%~1$VZqmI)!{*y1@(UqO9?GtgK}vk-T$x4Mv+{q<^^} zYuTK*kiDLj(-k2VYO8)6M1Z-C-bI+=5Vmm?ra2pCI2XrsqN^0PbM{Vz9h?KGlXGI% zIvsW!-bI`dv1+P#!hi(BrrKMPjs_|ozwJDT3 zFI7!OD77RJ4Q5NdFInzGsl4Q{`q$AtDiL-Xv?S#WwcKZ#ABtPiP-RB zVVze+yqQglhCMB$H$=mwikg;{*Hy!%tdcPihg}AviqaYe;1Ko^;wPkmkN_c#gfszB ziJ5G*8LQEptVJ`?1#XUA!pc=Kt*OxpI%`K1vlD6c5b)G}4L(6V!{`2?YJ1Kw-`ProX zl{R`UeYL$_ICQVRBz1@sza{06@rIum4_M<|V}$0KUTrrQyh45qvw_!|Eo``!h>dII zLWiKdNxlo)+JT~b~lm5b_GcJ_)4xV4b6Pl#gsPJ%Nt8H#N zvba1myUg*i_-tZ@=T>6NE3-33aJ8UjWl>cjP37{64E4lt=EV$TD|IVqX0}KrK}A$e zz@+N9>t;f3)5PnrLAc|-WnonkGxg$@WAU1}<=K_xx!Fq~t|6nO3pr99O<6`@9SivlUKX?K8>n<b_kHUiU&ajx&(yfLobyek;tl=}`f{#tF%w3udGloNu)eoSQ zdSuYa1L&G)cmWh1o0zEv4Sw3{c#d-=pr#iWmM+c$!gvI<7${5|zKSG1y&(V)fek_B z>*biW+5$TCLJW`ylSEC-K%sW=Vh*ZdHf_-BqGq%L(2@jomp22+3P4&CWf;S7B*==J zB2Uf%)iF#elo14fZYtMA9*z&Zc`(Qr%qn0NcHaUzZVzxke5j@4&eSJUJC{q%!{z4D-SKj8ycit6 zKlNa>6pU><^`5?R&t$P@@`1h7bGF=ecH8$4Z<`(r-5LF4RBwDuZ*0|@2LF=YJy9B( z+-p3ocb))6%`Av-ZU9C82H=4B(D7(_WTrSW^DtQ&xlkUwP;~Wedv$MH+1pk0cJ0iS zy+cLs&a{qX-fBgQNrT+6z zolH}3--EhF_nS=pZld2!^b-<7q28~F@_mFBDBcFgnd9%#9_-#pes=lO z%X`D8OFdKNp2Y{rhi??$THNbi+DFvSscyY{U}p;0qg{Fz``Of|Q+HFPuE<_z6#B+` z_1=-4_kcUmr}v$>>)&@XP0^>c!ynkUyIVZluKgC&-d%2s6x$-Zh5NaOsZv{_+?3dM z=)EU)FYUgw*FEv@y}e8xHWZB8pxXj*7(o0tJ8<{yzs~<@e)oE5@N{|b?1P_`*mK_u z4Db3%0~6mI8Mu3~Jb1D=c=G<`(%`w$5#kFl*@N1m;0pq}n}F^npcB%98bjOuXK4x| z;8_D|Klh9Qh@Sb-(Tmhm2E6t37z!>D*6sOG{S5@yNA2+_^#>~6!~8L31NQSCD$X*W z_Z|oSk(-JSFpoTB%A`Z+>Oi&}XzPiND-ph5my zg{zRt^Y;)Aktrnon5o|+sBh65 V-=eYanHWW#+6xZ+fCyu*{V&`Zvi|@8 literal 0 HcmV?d00001 diff --git a/app/agents/__pycache__/product_agent.cpython-313.pyc b/app/agents/__pycache__/product_agent.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af4b48f1a6bbaad95549b72e42ecceda9935541a GIT binary patch literal 4066 zcmahMTWs6b^-`qXl4Z-5?f4b?5!s4k$#vs6PTM4|?P_-7$4o15mTnwP(KZu`R7uK> z4Gb+>pzweULqCF~+ebZYK%4;s@-rZxN!+5p5>!A0(GJ79pZqy-i=lrzml72_i`NVA z@bW&+o9D3~4Ehj^*T%Z8KlUT^9saO~vohHqg2`>fAcnYvrU{jx@!WCAF-@u@jhayx zV#pT6Q16n%WciLttvX|_r}!gAm@Qm3D{ZjcsvzrZR#jF+Nx<(+R)^iXF}%KzlXWp8 z%!oHdnJ*(LLr3LfKMs@I=v|}|3{o8op^}V4r5F!GwV>;S>SUZPNOdtTfNsXEdREDp z*9^?7N;=2u7LBJApfCeKxw@NYhjh1R9c$dc#EfLlEpk#A}mM%&a znGsr=moNouEd5%t^@onA;>JDPwwdK@u{8u5OrqT~Q=fRdTwZntqjA zWi>%v7kR;S@*KY=m>x~gbx~f`OpmgR_XIWOF)2-uR#dPYQUe$SF{r^Hgh4F^bpSLh zCS4gOpUB91BAu9Frf5*mb%EElMA#mhWkaidM^3j%PPa56gDb=|5lTBBck znkz8Ua?58l)3<^Eeiz(_qlm969zL+dAH%c%FEBwjqQPV-v`{q@>{JFrqzJ~rkb?w6 zG0stH3LJP1?yM8H$ysLrZl;KZsu}HD2r;hV3bwb1zRGeMIH1uWdgXcdAR4Z4C_8|c zstdfpnIdeQFLNQ2Dbl9Goj^y);sIH_*Rb}iks@t;Af4|B>Cl4LKL6r9J zzEVcNO|Q!9r6YL_q=+N11Z^xw?AQFne#qWuYU3nR_iDMB@HPBvSOc_HZ`+1xh&z}F z(|Cl{n{3<%tPYmMUcxkElhz>SSjqvg

O|HU>D;mLdVRr>GkAx(`!MIAj<22L4rF z8i2ne<+6k5|M-WSIi7L@76(Hw4Ktl_O7{$XQbPJo4?ueMOi(}O%HNzCqA@M8=9!h#bXH2WPqzk#Q5gj^wFy|bIm zQ{(aZg}I5;_#!)&oSa=`nZ>b%#mNaXyqwcSSRALFSevA$T$4*N@LIo;APs7L`;{qq3J><_W<^D~KuE<;;f z3bdeQ1zzMN5fnlT8QRv@DLN~0vZB!fZKa{zdx{pPCvRpY@x2^g%g`&_rl8Ww3giS1 z-{W*~9dKBC!cBqCfr@Ev_ulT`1=@zeKUfrfdH9vv>DPk$T8xkkcMQF&FQ8WA7QZ} zbB!RgZ@g?(K#f_`G@yNs-t_vWz}T`57+j(?+YI<7Y;NE{=*2~_)Hzzk34AkjZT>(e z`z3AGRDE%Laq7xsj51xEti2B@+$RGIET~l%Oh0%5t6E;7nIxEHv*mzmmECxJZf5@S zBsi6N5-6D{Mw)>#Dwbd4!1AFrPGb)uvWf)TwVDpQ9d>I;(1kRV2p(3*KpGZ#lUx;a zGXkz9Ns#eb%Xw|DCnZ6KJr#-(QCJsPT!B>fRGb;jFerFkxgoGnxUg19k~XPjC|Y7( zHH;59En(F*JP#bgZEOT%ExWLgCA#6RxWkgN*_nF^jj6l=~)EU9f?Q{7++-U6I^P*7O9&(0!|3OZ#4`u)Y zsNvY1(N9P3UM<%5mg)y~hDza~LU`!G=)>h=cx=mUw0D-;M+)sD51qyK^QETqTY-Q0 zn~ZSdoqqXmET5axeIhf2{ih3J|5NU|9HnbG@tsduE% zJCZ*;RqUNMy5gm-p+eWtgExy^7oWMQy6~P4wZ`}AEc#wd-;3#E(1;p4zQ)A&5Sk%A zAfb-xzeqhpC^$wLkyazzb~p9;)z7Zxd(RfzM@#K<4^xlcD7-b7Z=2sk#LtO7qc5?& z0n^u7jaK^e(a%P=mx`_Nd`kk>20M(7lXrgwvs0Z$=c(=Bo|mdiJR@Df(4N!V;M?-- zHK69UQd7Lp6yM1`kRPoTo90S&b6YN>V_;`xXDQ!yCjZWr{Hg{Vg{K{`Zdoj|dB_?zS1+m}l{rwcu&A6zZ=TqqvL$^e;t zsA&+CfvxVvR`+77W6*$V8@Gbbc>-MEc@1j5@SFl0JrAH0mx*T-sOwoD3eV%I*Y~6L z0eII(&B+Au2O`-{{c+3z)5q;ZlBOPa^uzp#mq>O~Pki{ulP)3|rJi(;b-~9MP9hnj zzHp88!pD~bM6#dyGCtM^A73>R$pPxCCO4kb1cu!fj9M^e!F~(EwFGgf;Ke`U$MEc5 z1n~a~-lI!9oNi|+oK98T`9ThBs0IWaT`Iq6%whf1>L{Gia79a(e?#>&%+0PtzhU+M z!JmE-U&OJ%`Y|KNN|AUW0v=F|43|Q~dCzbmH2kB6EBKEh9mE}?jk@dXAY$%v-(od1 zxNj-r@`+U039&5X9Zi>@I*=9E zrtpT^fU#e|z!E9KTESZ6Sx$!#xD0)m#$iO#nVYhX2@y6W>#A zXXo?qkhAfJ>jdFEQ@(>U#%#=KHb9dUx5BfA>knvpp^K};)B{@(%y0v&V&r8~(|A@T sZ+hM=3wU0gfeqZ3Xe|KtNP-}~MQ?nI2EV7?CWtfn`ko&Uo>+JP3y9$7RR910 literal 0 HcmV?d00001 diff --git a/app/agents/__pycache__/prompting.cpython-313.pyc b/app/agents/__pycache__/prompting.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b5a95e339791934c088d77757dd097245c5b98fe GIT binary patch literal 1181 zcma)5&ubGw6rRm)(vmbAE2U_mFq9&VZ4;{$O7RCpuvV)`W56C{8FwdP+Wp1MM4R@K zqdnDwLjQ#Jq~Jdw^x`okhm-}qco1)e;N3ULR%vO$2eUJ8-uK@7zQ=4Qn@uCInT4^n z_X&i4DCfk$SEhAF;QIlUP)U1?9%)>|sAQB9BWTUwiK?DY{!*~K7G;R<2a-q@_`;5} zRS4<5rFG^tRjG_tU_WamZ8pBWqfNv?=O#k*wYe7v+?aD{2yFY&Q0Nqip=u4ZO)0~~T% zVH*K#SXl-LgZ3ai#QaUe>#0+A~9r6s8sxo_7T6V&X-hH|7|aDYA(IYuYjw5d7-Ku>zby0Lu0>F J8Evu){{zUjD<}W} literal 0 HcmV?d00001 diff --git a/app/agents/__pycache__/runtime.cpython-313.pyc b/app/agents/__pycache__/runtime.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f14d167230a4e86a49384dcc8e04255307c5ca70 GIT binary patch literal 15323 zcmeHOYj7Lab>0OQPl5mlkOT>ekSIc;NJ*6F2Tjs6Z3}X^f-Q%VP!L#>kbqr! z7tpIo({VZ-YTAzECLYn9OocRUEOqKeo%DzDD@y2xoaqlB>zPG%6pz|;I`y9pWqIPr zNoRV_E&vkHLP{r|PCDZq^5X8j=f2Lp_uTKCbN60-y`6w(_jvcoV?l!W1-_IXN{f*9 z_Cw@#f*~04IpSGTAZd&no-+sr_%%Lfe3lX@nrI=q35N0$jA_K6l@v^ihJg7adY8|yzwog@X5 z`erJ=nbkLQ@y)_CFcyppR>s3v)tHU(GB!14XBrv18mnWPm}aI9<4~i6@i7iHy`E`d z>eZN&aSEDW><6^%zl`q0D+bViJFah`rE zBPKXj6eoBneKtL4KP;B2q`9^4@bt34#u8#QMaOtI2pg+tM0-~o2$G%KXl_M<=_kJ4$LPh$E)Vlg8? z!k|4AGikG}hPVd5P7tutVCD1>G|c`SFjjlQul06`y=w} zO8Jaq(85{5mf6j-QeG%E;DmZkZ5krhry=M%|9JPPl)LG8;t z#9qm;^McyiNnYS^GhjBmxb}Uha#OSPVaO0!*pbQ8Y@8QpwKT{A0d^T|>lAGGXk1V? zK1!FF3D|(*G86iN*)*idE|pD})pa>^u}9id);7qAG(Ur`U6f zT?7dOG7C1oVuN8uxim(tDlaf*68KD-Rm>_A(uy5=B0?r+SwJFi9Gkpm5gaf#LOljf z3|tt%j1vNE9zwAf38BzuX0_%)h2lVFD3%O&isR34*GR<{<5SB#2Mi9F6w4wD2Sr*? zj1W=GndNvCI$>d9Y9oo5Vw*Y}W0&zDxn@-;kxecrR1BD{m_#TBgv-%$NglY>ihZzQ zAB?f|S%sR9!gz5Z(ZxqO9!zoRU_5w;nW0}0_>*ibEe5sGFQ8aE!waV(d@K>1kH${% z3&3hNqH!^ui1U#$O%P~i z!_gm%-aIB9oGr9=TxoryHG5EM4HjD3uh`zOWw%Q$I}1(z%P+t7^6D9>slU+Nb~*7{ zB5ReJ0|kHkmHs#SvjdVpSc(TE|G-_B)oov));)x+{$kyQx=YiS53LFxSi9E^Mw_?b z@ynj>yr(-me4|w!edU(Q-Dtp+ zW)kB*BgDTrM&PLX;ut9cfY0d|c^Vd_c8sv6K_KyT=SSJv6gur3XZk)d{x!-ifWOzLVnWle08&H>UA zG&gmjiIdFZKBu4P{&a< z#^5MvZq;Q#D+bM4cO3N(DPyit#;H?gTphcUQ( zuPW$JhpKu?bo#Y`6|RvYyu^!k(GVm=gO_*)>frx=7Im)wjBGULqbaD7KBZ9s?#E`3P+#0gJE~ zegs(Cpy(z+`{ZKhA}LXE#`#zVr8zVU`k>+}Hp(Uzm*#mvRNQHPIZ`G{#6`u9DPoKl zSTSJVz

RKszBoQKca&Mqv1lj4*)>8ac3)BfTqzK&@ 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 gY2K~20qj~a#$TbgzCwfFFc dict[str, Any]: + await self._emit_ic( + "AGA.001", + state, + { + "status": "Entrada recebida pelo agente nativo do framework", + "framework_native": True, + "business_component": "backoffice_anatel", + }, + component="agent.backoffice.native.start", + ) + + normalized_state = self._normalize_state_tools(state) + await self._emit_ic( + "AGA.018", + normalized_state, + { + "status": "Contexto canônico validado pelo framework", + "missing_fields": self._missing_required_context(normalized_state), + "framework_native": True, + }, + component="agent.backoffice.native.context", + ) + + if self._has_original_ticket_context(normalized_state): + await self._emit_ic( + "BACKOFFICE_TICKET_CONTEXT_DETECTED", + normalized_state, + { + "status": "Contexto de ticket detectado; execução checklist deve ocorrer pelo BackofficeNativeRuntime nas rotas /agent/*", + "framework_native": True, + "reason": "o agente conversacional não compila nem executa grafos de domínio", + }, + component="agent.backoffice.native.ticket_context", + ) + + rag_context, rag_metadata = await self._retrieve_rag_context(normalized_state) + if rag_metadata.get("enabled"): + await self._emit_ic( + "AGA.012", + normalized_state, + { + "status": "RAG/TAIS KB consultado pelo serviço nativo do framework", + "document_count": rag_metadata.get("document_count", 0), + "top_document_ids": rag_metadata.get("top_document_ids", []), + }, + component="agent.backoffice.native.rag", + ) + + mcp_results = await self._execute_tools_by_intent(normalized_state) + await self._emit_ic( + "AGA.014", + normalized_state, + { + "status": "Ferramentas MCP selecionadas pelo roteador do framework", + "intent": normalized_state.get("intent"), + "tool_count": len(mcp_results), + "tools": [r.get("tool_name") or r.get("tool") for r in mcp_results], + }, + component="agent.backoffice.native.tools", + ) + + answer = await self._generate_answer(normalized_state, rag_context, rag_metadata, mcp_results) + + await self._emit_ic( + "AGA.043", + normalized_state, + { + "status": "Resposta produzida pelo agente nativo e entregue ao workflow do framework", + "answer_chars": len(answer or ""), + "framework_native": True, + }, + component="agent.backoffice.native.completed", + ) + await self._emit_noc( + "001", + normalized_state, + {"message": "Backoffice native agent completed", "type": "INFO"}, + component="agent.backoffice.native.completed", + ) + + return { + "answer": answer, + "next_state": self._next_state(normalized_state, mcp_results), + "mcp_results": mcp_results, + "rag_metadata": rag_metadata, + "framework_native": { + "agent": self.name, + "intent": normalized_state.get("intent"), + "route": normalized_state.get("route"), + "tools": normalized_state.get("mcp_tools") or [], + }, + } + + def _normalize_state_tools(self, state: dict[str, Any]) -> dict[str, Any]: + intent = state.get("intent") or "backoffice_anatel_triage" + configured_tools = list(state.get("mcp_tools") or []) + default_tools = self.DEFAULT_TOOLS_BY_INTENT.get(intent, self.DEFAULT_TOOLS_BY_INTENT["backoffice_anatel_triage"]) + tools = configured_tools or default_tools + + # Garante ordem estável e remove duplicidade sem perder a configuração do roteador. + seen: set[str] = set() + deduped = [] + for tool in tools: + if tool and tool not in seen: + seen.add(tool) + deduped.append(tool) + + return { + **state, + "route": state.get("route") or self.name, + "active_agent": self.name, + "intent": intent, + "mcp_tools": deduped, + } + + def _missing_required_context(self, state: dict[str, Any]) -> list[str]: + ctx = state.get("context") or {} + bc = ctx.get("business_context") or state.get("business_context") or {} + interaction = (bc.get("interaction_key") if isinstance(bc, dict) else None) or ctx.get("interaction_key") + if not interaction: + interaction = ( + ctx.get("protocol_id") + or ctx.get("protocolo") + or ctx.get("complaint_id") + or ctx.get("complaintProtocol") + or ctx.get("transactionId") + or ctx.get("transaction_id") + or ctx.get("ticket_id") + ) + return [] if interaction else ["interaction_key"] + + async def _execute_tools_by_intent(self, state: dict[str, Any]) -> list[dict[str, Any]]: + intent = state.get("intent") or "backoffice_anatel_triage" + tools = list(state.get("mcp_tools") or []) + results: list[dict[str, Any]] = [] + + for tool in tools: + arguments = self._tool_arguments(tool, intent, state) + if tool.startswith("registrar_") and not arguments.get("action_text"): + # O framework não deve registrar ação operacional sem texto explícito. + results.append({ + "ok": False, + "tool_name": tool, + "skipped": True, + "reason": "action_text ausente; registro não executado por segurança operacional", + }) + await self._emit_ic( + "AGA.008", + state, + {"status": "Registro operacional não executado sem action_text", "tool_name": tool}, + component="agent.backoffice.native.tool.skip", + ) + continue + + result = await self._call_mcp_tool(tool, arguments, state) + results.append(result) + if tool in {"consultar_speech_analytics"}: + await self._emit_ic("AGA.010", state, {"status": "Speech Analytics consultado", "tool_ok": result.get("ok")}, component="agent.backoffice.native.speech") + elif tool in {"consultar_imdb_cliente", "consultar_cliente_backoffice"}: + await self._emit_ic("AGA.011", state, {"status": "Cliente/IMDB consultado", "tool_ok": result.get("ok")}, component="agent.backoffice.native.customer") + elif tool in {"consultar_tais_kb", "buscar_templates_emulador"}: + await self._emit_ic("AGA.020", state, {"status": "Templates/TAIS consultados", "tool_ok": result.get("ok")}, component="agent.backoffice.native.templates") + elif tool in {"registrar_acao_backoffice", "registrar_acao_siebel"}: + await self._emit_ic("AGA.006", state, {"status": "Ação operacional solicitada", "tool_ok": result.get("ok")}, component="agent.backoffice.native.action") + + return results + + def _tool_arguments(self, tool: str, intent: str, state: dict[str, Any]) -> dict[str, Any]: + ctx = state.get("context") or {} + # Para query/prompt usamos o texto sanitizado; para extração de CPF/CNPJ + # precisamos do texto original, pois o guardrail MSK mascara o documento. + text = state.get("sanitized_input") or state.get("user_text") or "" + original_text = ( + ctx.get("message") + or ctx.get("text") + or ctx.get("query") + or state.get("user_text") + or text + or "" + ) + bc = ctx.get("business_context") or state.get("business_context") or {} + explicit = ctx.get("tool_arguments") or state.get("tool_arguments") or {} + + def pick(*names: str) -> Any: + for name in names: + cur: Any = None + if name in explicit: + cur = explicit.get(name) + elif isinstance(bc, dict) and name in bc: + cur = bc.get(name) + elif name in ctx: + cur = ctx.get(name) + elif name in state: + cur = state.get(name) + if cur not in (None, "", {}, []): + return cur + return None + + protocol_id = pick( + "protocol_id", + "protocolo", + "interaction_key", + "complaint_id", + "complaintProtocol", + "transactionId", + "transaction_id", + "ticket_id", + ) + extracted_identity = extract_identity_from_text(str(original_text)) + # Identificador explicitamente digitado na mensagem atual prevalece sobre + # defaults da sessão/front (ex.: user_id/msisdn 11999999999). + customer_key = extracted_identity.get("customer_key") or pick("customer_key", "cpf", "cnpj", "document", "msisdn", "customer_id", "cpf_hash", "document_hash", "user_id") + if not protocol_id: + protocol_id = extracted_identity.get("protocol_id") or extracted_identity.get("interaction_key") + contract_key = pick("contract_key", "contract_id", "account_id", "asset_id") + session_key = pick("session_key", "conversation_key", "session_id") + + # Extra args são passados ao MCPToolRouter. O mapper do framework ainda + # aplica mcp_parameter_mapping.yaml, mas estes aliases tornam a chamada + # resiliente para tools que exigem protocol_id diretamente. + common = { + "query": text, + "operator_instructions": text, + "selected_actions": explicit.get("selected_actions") or [], + } + if protocol_id: + common["protocol_id"] = str(protocol_id) + common["interaction_key"] = str(protocol_id) + if customer_key: + common["customer_key"] = str(customer_key) + # Mantém aliases documentais para tools TIM que aceitam CPF/CNPJ/document. + for identity_key in ("cpf", "cnpj", "document", "document_type"): + if extracted_identity.get(identity_key): + common[identity_key] = str(extracted_identity[identity_key]) + if contract_key: + common["contract_key"] = str(contract_key) + if session_key: + common["session_key"] = str(session_key) + common["operator_session"] = str(session_key) + + if tool.startswith("registrar_"): + action_text = explicit.get("action_text") + if not action_text and intent == "backoffice_action_register": + action_text = text + common["action_text"] = action_text + return common + + async def _generate_answer( + self, + state: dict[str, Any], + rag_context: str, + rag_metadata: dict[str, Any], + mcp_results: list[dict[str, Any]], + ) -> str: + missing = self._missing_required_context(state) + if missing: + return ( + "[BackofficeAgent] Para seguir no padrão do framework, preciso do identificador canônico " + f"{', '.join(missing)}. Informe o protocolo/reclamação/chamado ou configure o mapping em identity.yaml." + ) + + system_prompt = apply_agent_profile_prompt(state, self._system_prompt()) + messages = [ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": ( + "Mensagem do usuário:\n" + f"{state.get('sanitized_input') or state.get('user_text') or ''}\n\n" + "Intent/rota escolhidos pelo framework:\n" + f"intent={state.get('intent')} route={state.get('route')}\n\n" + "Resultados MCP normalizados pelo framework:\n" + f"{mcp_results}\n\n" + "Contexto RAG nativo do framework:\n" + f"{rag_context or '[sem contexto RAG]'}\n\n" + "Metadados RAG:\n" + f"{rag_metadata}" + ), + }, + ] + try: + generated = await self._invoke_llm_cached(state, "BackofficeNativeAgent", messages) + return f"[BackofficeAgent] {generated}" + except Exception as exc: + await self._emit_noc( + "005", + state, + {"message": f"LLM failed in native backoffice agent: {type(exc).__name__}: {exc}", "type": "FAILURE"}, + component="agent.backoffice.native.llm", + ) + return self._fallback_answer(state, mcp_results) + + def _has_original_ticket_context(self, state: dict[str, Any]) -> bool: + ctx = state.get("context") or {} + rc = ctx.get("request_context") or ctx + return bool( + rc.get("transactionId") + and isinstance(rc.get("complaint") or {}, dict) + and isinstance(rc.get("customer") or {}, dict) + ) + + async def _run_original_checklist_workflow(self, state: dict[str, Any]) -> dict[str, Any]: + """Deprecated compatibility stub. + + A migração framework-native não permite que o agente conversacional + compile/execute ``src.agent.graphs`` diretamente. Os fluxos checklist e + response emulator são executados pelo BackofficeNativeRuntime chamado + pelas rotas/adapters do backend. + """ + return { + "executed": False, + "error": { + "type": "DeprecatedDirectGraphExecution", + "message": "Use BackofficeNativeRuntime.execute_workflow('backoffice_checklist')", + }, + } + + def _system_prompt(self) -> str: + return """ +Você é o agente Backoffice/ANATEL executando exclusivamente pelo framework corporativo. + +Use apenas estes insumos: +- BusinessContext canônico do framework. +- Ferramentas MCP selecionadas pelo EnterpriseRouter. +- Contexto RAG retornado pelo RagService do framework. +- Memória/checkpoint já carregados pelo workflow. + +Regras obrigatórias: +1. Não invente protocolo, cliente, contrato, status, SLA, parecer ou ação Siebel. +2. Se uma ferramenta MCP retornar erro ou ausência de dados, diga exatamente que a evidência não foi encontrada. +3. Não confirme registro de ação sem retorno ok/registered da tool correspondente. +4. Se faltar protocolo/chamado/reclamação, peça somente esse identificador. +5. Responda de forma operacional, curta e rastreável. +6. A resposta será validada por OutputSupervisor, guardrails de saída e judges do framework. +""".strip() + + def _fallback_answer(self, state: dict[str, Any], mcp_results: list[dict[str, Any]]) -> str: + ok_tools = [r.get("tool_name") or r.get("tool") for r in mcp_results if r.get("ok")] + failed_tools = [r.get("tool_name") or r.get("tool") for r in mcp_results if not r.get("ok")] + return ( + "[BackofficeAgent] Fluxo nativo executado pelo framework. " + f"Intent: {state.get('intent')}. " + f"Tools com sucesso: {ok_tools or 'nenhuma'}. " + f"Tools pendentes/erro: {failed_tools or 'nenhuma'}. " + "A resposta final não foi enriquecida pelo LLM porque houve falha controlada nessa etapa." + ) + + def _next_state(self, state: dict[str, Any], mcp_results: list[dict[str, Any]]) -> str: + if self._missing_required_context(state): + return "WAITING_BACKOFFICE_IDENTIFIER" + if any(r.get("skipped") for r in mcp_results): + return "BACKOFFICE_WAITING_ACTION_TEXT" + return "BACKOFFICE_ACTIVE" diff --git a/app/agents/billing_agent.py b/app/agents/billing_agent.py new file mode 100644 index 0000000..8a830aa --- /dev/null +++ b/app/agents/billing_agent.py @@ -0,0 +1,70 @@ +from app.agents.prompting import apply_agent_profile_prompt +from app.agents.runtime import AgentRuntimeMixin + +class BillingAgent(AgentRuntimeMixin): + name = "billingAgent" + + def __init__(self, llm, telemetry=None, tool_router=None, rag_service=None, cache=None, settings=None, observer=None): + self.llm = llm + self.telemetry = telemetry + self.tool_router = tool_router + self.rag_service = rag_service + self.cache = cache + self.settings = settings + self.observer = observer + + async def run(self, state): + await self._emit_ic( + "IC.BILLING_AGENT_STARTED", + state, + {"business_component": "faturas"}, + component="agent.billing.start", + ) + session = (state.get("context") or {}).get("session", {}) + tool_context = await self._collect_tool_context(state) + if tool_context: + await self._emit_ic( + "IC.BILLING_MCP_CONTEXT_COLLECTED", + state, + {"tool_result_count": len(tool_context)}, + component="agent.billing.mcp", + ) + rag_context, rag_metadata = await self._retrieve_rag_context(state) + if rag_metadata.get("enabled"): + await self._emit_ic( + "IC.BILLING_RAG_CONTEXT_RETRIEVED", + state, + { + "document_count": rag_metadata.get("document_count"), + "graph_neighbors": rag_metadata.get("graph_neighbors"), + "latency_ms": rag_metadata.get("latency_ms"), + }, + component="agent.billing.rag", + ) + messages = [ + {"role": "system", "content": apply_agent_profile_prompt(state, "Você é um agente especialista em faturas. Responda com clareza, objetividade e sem sugerir ações não solicitadas. Use dados MCP quando disponíveis.")}, + {"role": "user", "content": ( + f"Mensagem: {state.get('sanitized_input') or state['user_text']}\n" + f"Contexto de sessão: {session}\n" + f"Intent: {state.get('intent')}\n" + f"Dados MCP: {tool_context}\n" + f"Contexto RAG: {rag_context}" + )}, + ] + answer = await self._invoke_llm_cached(state, "BillingAgent", messages) + result = {"answer": f"[BillingAgent] {answer}", "next_state": "BILLING_ACTIVE", "mcp_results": tool_context, "rag": rag_metadata} + await self._emit_ic( + "IC.BILLING_AGENT_COMPLETED", + state, + { + "answer_chars": len(result.get("answer") or ""), + "has_mcp_results": bool(tool_context), + "rag_enabled": bool(rag_metadata.get("enabled")), + }, + component="agent.billing.completed", + ) + return result + + + async def _collect_tool_context(self, state): + return await self._collect_mcp_context(state) diff --git a/app/agents/orders_agent.py b/app/agents/orders_agent.py new file mode 100644 index 0000000..c6529a3 --- /dev/null +++ b/app/agents/orders_agent.py @@ -0,0 +1,69 @@ +from app.agents.prompting import apply_agent_profile_prompt +from app.agents.runtime import AgentRuntimeMixin + +class OrdersAgent(AgentRuntimeMixin): + name = "orders_agent" + + def __init__(self, llm, telemetry=None, tool_router=None, rag_service=None, cache=None, settings=None, observer=None): + self.llm = llm + self.telemetry = telemetry + self.tool_router = tool_router + self.rag_service = rag_service + self.cache = cache + self.settings = settings + self.observer = observer + + async def run(self, state): + await self._emit_ic( + "IC.ORDERS_AGENT_STARTED", + state, + {"business_component": "pedidos"}, + component="agent.orders.start", + ) + session = (state.get("context") or {}).get("session", {}) + tool_context = await self._collect_tool_context(state) + if tool_context: + await self._emit_ic( + "IC.ORDERS_MCP_CONTEXT_COLLECTED", + state, + {"tool_result_count": len(tool_context)}, + component="agent.orders.mcp", + ) + rag_context, rag_metadata = await self._retrieve_rag_context(state) + if rag_metadata.get("enabled"): + await self._emit_ic( + "IC.ORDERS_RAG_CONTEXT_RETRIEVED", + state, + { + "document_count": rag_metadata.get("document_count"), + "graph_neighbors": rag_metadata.get("graph_neighbors"), + "latency_ms": rag_metadata.get("latency_ms"), + }, + component="agent.orders.rag", + ) + messages = [ + {"role": "system", "content": apply_agent_profile_prompt(state, "Você é um agente de pedidos de varejo. Use dados de tools quando disponíveis.")}, + {"role": "user", "content": ( + f"Mensagem: {state.get('sanitized_input') or state['user_text']}\n" + f"Sessão: {session}\n" + f"Intent: {state.get('intent')}\n" + f"Dados MCP: {tool_context}\n" + f"Contexto RAG: {rag_context}" + )}, + ] + answer = await self._invoke_llm_cached(state, "OrdersAgent", messages) + result = {"answer": f"[OrdersAgent] {answer}", "next_state": "ORDER_ACTIVE", "mcp_results": tool_context, "rag": rag_metadata} + await self._emit_ic( + "IC.ORDERS_AGENT_COMPLETED", + state, + { + "answer_chars": len(result.get("answer") or ""), + "has_mcp_results": bool(tool_context), + "rag_enabled": bool(rag_metadata.get("enabled")), + }, + component="agent.orders.completed", + ) + return result + + async def _collect_tool_context(self, state): + return await self._collect_mcp_context(state) diff --git a/app/agents/product_agent.py b/app/agents/product_agent.py new file mode 100644 index 0000000..c521a8c --- /dev/null +++ b/app/agents/product_agent.py @@ -0,0 +1,70 @@ +from app.agents.prompting import apply_agent_profile_prompt +from app.agents.runtime import AgentRuntimeMixin + +class ProductAgent(AgentRuntimeMixin): + name = "productAgent" + + def __init__(self, llm, telemetry=None, tool_router=None, rag_service=None, cache=None, settings=None, observer=None): + self.llm = llm + self.telemetry = telemetry + self.tool_router = tool_router + self.rag_service = rag_service + self.cache = cache + self.settings = settings + self.observer = observer + + async def run(self, state): + await self._emit_ic( + "IC.PRODUCT_AGENT_STARTED", + state, + {"business_component": "produtos"}, + component="agent.product.start", + ) + session = (state.get("context") or {}).get("session", {}) + tool_context = await self._collect_tool_context(state) + if tool_context: + await self._emit_ic( + "IC.PRODUCT_MCP_CONTEXT_COLLECTED", + state, + {"tool_result_count": len(tool_context)}, + component="agent.product.mcp", + ) + rag_context, rag_metadata = await self._retrieve_rag_context(state) + if rag_metadata.get("enabled"): + await self._emit_ic( + "IC.PRODUCT_RAG_CONTEXT_RETRIEVED", + state, + { + "document_count": rag_metadata.get("document_count"), + "graph_neighbors": rag_metadata.get("graph_neighbors"), + "latency_ms": rag_metadata.get("latency_ms"), + }, + component="agent.product.rag", + ) + messages = [ + {"role": "system", "content": apply_agent_profile_prompt(state, "Você é um agente especialista em produtos, planos e serviços. Explique sem fazer oferta proativa e sem executar ações sem confirmação. Use dados MCP quando disponíveis.")}, + {"role": "user", "content": ( + f"Mensagem: {state.get('sanitized_input') or state['user_text']}\n" + f"Contexto de sessão: {session}\n" + f"Intent: {state.get('intent')}\n" + f"Dados MCP: {tool_context}\n" + f"Contexto RAG: {rag_context}" + )}, + ] + answer = await self._invoke_llm_cached(state, "ProductAgent", messages) + result = {"answer": f"[ProductAgent] {answer}", "next_state": "PRODUCT_ACTIVE", "mcp_results": tool_context, "rag": rag_metadata} + await self._emit_ic( + "IC.PRODUCT_AGENT_COMPLETED", + state, + { + "answer_chars": len(result.get("answer") or ""), + "has_mcp_results": bool(tool_context), + "rag_enabled": bool(rag_metadata.get("enabled")), + }, + component="agent.product.completed", + ) + return result + + + async def _collect_tool_context(self, state): + return await self._collect_mcp_context(state) diff --git a/app/agents/prompting.py b/app/agents/prompting.py new file mode 100644 index 0000000..255422b --- /dev/null +++ b/app/agents/prompting.py @@ -0,0 +1,15 @@ +from __future__ import annotations + + +def apply_agent_profile_prompt(state: dict, default_prompt: str) -> str: + """Adiciona o prefixo de prompt configurado para o agent_template selecionado. + + Cada agent_id pode definir metadata.system_prefix em config/agents.yaml. Isso + mantém prompts isolados sem duplicar o código dos agentes especializados. + """ + profile = state.get("agent_profile") or (state.get("context") or {}).get("agent_profile") or {} + metadata = profile.get("metadata") or {} + prefix = (metadata.get("system_prefix") or "").strip() + if not prefix: + return default_prompt + return f"{prefix}\n\n{default_prompt}" diff --git a/app/agents/runtime.py b/app/agents/runtime.py new file mode 100644 index 0000000..9e6ca25 --- /dev/null +++ b/app/agents/runtime.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import hashlib +from typing import Any + + +class AgentRuntimeMixin: + """Mixin operacional para agentes. + + Integra RAG, cache, telemetria e chamadas MCP usando BusinessContext. + Os agentes não precisam conhecer nomes reais de parâmetros do domínio + (msisdn, invoice_id, order_id etc.); eles repassam as chaves canônicas e + o MCPParameterMapper converte para cada tool configurada. + """ + + + async def _emit_ic(self, code: str, state: dict[str, Any], payload: dict[str, Any] | None = None, component: str | None = None) -> None: + """Emite Item de Controle (IC) sem impactar a execução do agente. + + Este helper é intencionalmente fail-open: erro de observabilidade não + pode quebrar a jornada de negócio do agente. O desenvolvedor pode usar + o mesmo padrão para ICs específicos da sua squad. + """ + observer = getattr(self, "observer", None) + if not observer: + return + ctx = state.get("context") or {} + base = { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "route": state.get("route"), + "intent": state.get("intent"), + "message_id": ctx.get("message_id"), + "channel_id": ctx.get("channel"), + } + base.update(payload or {}) + try: + await observer.emit_ic(code, base, component=component or f"agent.{getattr(self, 'name', 'unknown')}") + except Exception: + return + + async def _emit_noc(self, code: str, state: dict[str, Any], payload: dict[str, Any] | None = None, component: str | None = None) -> None: + """Emite evento NOC sem acoplar a lógica de negócio à observabilidade.""" + observer = getattr(self, "observer", None) + if not observer: + return + ctx = state.get("context") or {} + base = { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "route": state.get("route"), + "intent": state.get("intent"), + "message_id": ctx.get("message_id"), + "channel_id": ctx.get("channel"), + } + base.update(payload or {}) + try: + await observer.emit_noc(code, base, component=component or f"agent.{getattr(self, 'name', 'unknown')}") + except Exception: + return + + async def _emit_grl(self, code: str, state: dict[str, Any], payload: dict[str, Any] | None = None, component: str | None = None) -> None: + """Emite evento GRL opcional para custom rails implementados no backend.""" + observer = getattr(self, "observer", None) + if not observer: + return + ctx = state.get("context") or {} + base = { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "route": state.get("route"), + "intent": state.get("intent"), + "message_id": ctx.get("message_id"), + "channel_id": ctx.get("channel"), + } + base.update(payload or {}) + try: + await observer.emit_grl(code, base, component=component or f"agent.{getattr(self, 'name', 'unknown')}") + except Exception: + return + + async def _retrieve_rag_context(self, state: dict[str, Any]) -> tuple[str, dict[str, Any]]: + rag_service = getattr(self, "rag_service", None) + if not rag_service: + return "", {"enabled": False} + text = state.get("sanitized_input") or state.get("user_text") or "" + namespace = ( + (state.get("agent_profile") or {}).get("rag_namespace") + or state.get("agent_id") + or state.get("route") + or "default" + ) + ctx = state.get("context") or {} + business_context = ctx.get("business_context") or {} + graph_node = ( + ctx.get("graph_node") + or business_context.get("customer_key") + or business_context.get("contract_key") + or ctx.get("customer_id") + ) + result = await rag_service.retrieve(text, namespace=namespace, graph_node=graph_node) + context = result.as_prompt_context() + return context, { + "enabled": True, + "namespace": namespace, + "latency_ms": result.latency_ms, + "document_count": len(result.documents), + "graph_neighbors": len(result.graph_neighbors), + "top_document_ids": [d.id for d in result.documents[:5]], + "top_scores": [d.score for d in result.documents[:5]], + } + + async def _call_mcp_tool(self, tool: str, arguments: dict[str, Any] | None, state: dict[str, Any]) -> dict[str, Any]: + """Chama uma ferramenta via MCPToolRouter usando o contrato canônico do framework. + + Use este helper quando o agente precisa passar argumentos específicos + além do BusinessContext mapeado em mcp_parameter_mapping.yaml. + Observabilidade IC.MCP_TOOL_CALLED/IC.TOOL_CALLED permanece uniforme. + """ + if not getattr(self, "tool_router", None): + return {"ok": False, "tool_name": tool, "error": "tool_router não configurado"} + ctx = state.get("context") or {} + business_context = ctx.get("business_context") or state.get("business_context") or {} + original_context = { + **ctx, + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "session_id": state.get("conversation_key") or state.get("session_id"), + "conversation_key": state.get("conversation_key") or state.get("session_id"), + } + observer = getattr(self, "observer", None) + if observer: + await observer.emit_ic( + "IC.MCP_TOOL_CALLED", + { + "session_id": original_context.get("conversation_key") or original_context.get("session_id"), + "tenant_id": original_context.get("tenant_id"), + "agent_id": original_context.get("agent_id"), + "tool_name": tool, + "framework_native": True, + }, + component="agent_runtime.native_mcp", + ) + try: + res = await self.tool_router.call( + tool, + arguments or {}, + business_context=business_context, + original_context=original_context, + ) + result_payload = res.model_dump(mode="json") if hasattr(res, "model_dump") else dict(res) + except Exception as exc: + result_payload = {"ok": False, "tool_name": tool, "error": str(exc), "error_type": type(exc).__name__} + result_payload.setdefault("tool_name", tool) + if observer: + await observer.emit_ic( + "IC.TOOL_CALLED", + { + "session_id": original_context.get("conversation_key") or original_context.get("session_id"), + "tenant_id": original_context.get("tenant_id"), + "agent_id": original_context.get("agent_id"), + "tool_name": tool, + "ok": result_payload.get("ok"), + "server_name": result_payload.get("server_name"), + "error": result_payload.get("error"), + "framework_native": True, + }, + component="agent_runtime.native_mcp", + ) + return result_payload + + async def _collect_mcp_context(self, state: dict[str, Any]) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + if not getattr(self, "tool_router", None): + return results + tools = state.get("mcp_tools") or [] + ctx = state.get("context") or {} + business_context = ctx.get("business_context") or state.get("business_context") or {} + original_context = { + **ctx, + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "session_id": state.get("conversation_key") or state.get("session_id"), + "conversation_key": state.get("conversation_key") or state.get("session_id"), + } + for tool in tools: + observer = getattr(self, "observer", None) + if observer: + await observer.emit_ic( + "IC.MCP_TOOL_CALLED", + { + "session_id": original_context.get("conversation_key") or original_context.get("session_id"), + "tenant_id": original_context.get("tenant_id"), + "agent_id": original_context.get("agent_id"), + "tool_name": tool, + }, + component="agent_runtime", + ) + res = await self.tool_router.call( + tool, + {}, + business_context=business_context, + original_context=original_context, + ) + result_payload = res.model_dump(mode="json") + if observer: + await observer.emit_ic( + "IC.TOOL_CALLED", + { + "session_id": original_context.get("conversation_key") or original_context.get("session_id"), + "tenant_id": original_context.get("tenant_id"), + "agent_id": original_context.get("agent_id"), + "tool_name": tool, + "ok": result_payload.get("ok"), + "server_name": result_payload.get("server_name"), + "error": result_payload.get("error"), + }, + component="agent_runtime", + ) + results.append(result_payload) + return results + + async def _cache_get(self, key: str): + cache = getattr(self, "cache", None) + if not cache: + return None + return await cache.get(key) + + async def _cache_set(self, key: str, value: Any, ttl_seconds: int | None = None): + cache = getattr(self, "cache", None) + if not cache: + return + await cache.set(key, value, ttl_seconds) + + def _llm_cache_key(self, state: dict[str, Any], agent_name: str, prompt_parts: list[Any]) -> str: + business_context = (state.get("context") or {}).get("business_context") or {} + raw = "|".join([ + agent_name, + state.get("tenant_id") or "", + state.get("agent_id") or "", + state.get("intent") or "", + business_context.get("customer_key") or "", + business_context.get("contract_key") or "", + business_context.get("interaction_key") or "", + state.get("sanitized_input") or state.get("user_text") or "", + repr(prompt_parts), + ]) + return "llm:" + hashlib.sha256(raw.encode("utf-8")).hexdigest() + + async def _invoke_llm_cached(self, state: dict[str, Any], agent_name: str, messages: list[dict[str, str]]): + ttl = int(getattr(getattr(self, "settings", None), "CACHE_TTL_SECONDS", 300) or 300) + key = self._llm_cache_key(state, agent_name, messages) + cached = await self._cache_get(key) + if cached is not None: + telemetry = getattr(self, "telemetry", None) + if telemetry: + await telemetry.event("cache.llm.hit", {"agent": agent_name, "key": key}, kind="cache") + return cached + telemetry = getattr(self, "telemetry", None) + if telemetry: + await telemetry.event("cache.llm.miss", {"agent": agent_name, "key": key}, kind="cache") + answer = await self.llm.ainvoke(messages) + await self._cache_set(key, answer, ttl) + return answer diff --git a/app/agents/support_agent.py b/app/agents/support_agent.py new file mode 100644 index 0000000..8241666 --- /dev/null +++ b/app/agents/support_agent.py @@ -0,0 +1,67 @@ +from app.agents.prompting import apply_agent_profile_prompt +from app.agents.runtime import AgentRuntimeMixin + +class SupportAgent(AgentRuntimeMixin): + name = "support_agent" + + def __init__(self, llm, telemetry=None, tool_router=None, rag_service=None, cache=None, settings=None, observer=None): + self.llm = llm + self.telemetry = telemetry + self.tool_router = tool_router + self.rag_service = rag_service + self.cache = cache + self.settings = settings + self.observer = observer + + async def run(self, state): + await self._emit_ic( + "IC.SUPPORT_AGENT_STARTED", + state, + {"business_component": "suporte"}, + component="agent.support.start", + ) + tool_context = await self._collect_tool_context(state) + if tool_context: + await self._emit_ic( + "IC.SUPPORT_MCP_CONTEXT_COLLECTED", + state, + {"tool_result_count": len(tool_context)}, + component="agent.support.mcp", + ) + rag_context, rag_metadata = await self._retrieve_rag_context(state) + if rag_metadata.get("enabled"): + await self._emit_ic( + "IC.SUPPORT_RAG_CONTEXT_RETRIEVED", + state, + { + "document_count": rag_metadata.get("document_count"), + "graph_neighbors": rag_metadata.get("graph_neighbors"), + "latency_ms": rag_metadata.get("latency_ms"), + }, + component="agent.support.rag", + ) + messages = [ + {"role": "system", "content": apply_agent_profile_prompt(state, "Você é um agente de suporte de varejo para troca, devolução e garantia.")}, + {"role": "user", "content": ( + f"Mensagem: {state.get('sanitized_input') or state['user_text']}\n" + f"Intent: {state.get('intent')}\n" + f"Dados MCP: {tool_context}\n" + f"Contexto RAG: {rag_context}" + )}, + ] + answer = await self._invoke_llm_cached(state, "SupportAgent", messages) + result = {"answer": f"[SupportAgent] {answer}", "next_state": "SUPPORT_ACTIVE", "mcp_results": tool_context, "rag": rag_metadata} + await self._emit_ic( + "IC.SUPPORT_AGENT_COMPLETED", + state, + { + "answer_chars": len(result.get("answer") or ""), + "has_mcp_results": bool(tool_context), + "rag_enabled": bool(rag_metadata.get("enabled")), + }, + component="agent.support.completed", + ) + return result + + async def _collect_tool_context(self, state): + return await self._collect_mcp_context(state) diff --git a/app/domain/.DS_Store b/app/domain/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..8fad58b63c9618eb93feb958ec42cc30f68f9143 GIT binary patch literal 6148 zcmeHKy-veG4ED7h3SBxf-Yaxx2vzz7UDyIi3q>?a76!Ik*?A|Pg}31Ij|wFT5(}!3 zUD@BovG1JksyIhPVfnOK5Y35b3S}If!So2Z+;Qo?!H#R|5G}r zQtEu9hVJltK;7GK)xHkJ4b<8@XWjHx+8f(bC)euY3^)VMz$r3-p3O3yDY|tAoB?Ox zzyQA=B9t*ROp5aAfRQ5ra00go#`28@jO+krhDi|~NYGTErUpA=1WkuOT3lwB6g8cK ztc-bV*bvL|7!UBf1Ko3&VV!UuNVlEe4Q`xNY-0B v568VWLT{if9G4UiDHupAMl84D3#bVEkq5xcFe$5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~h`er{fgeoA6V zqJCOtMSOBWntozIfqqJUZenJhetdjpUS>&ryk0@&Ee@O9{FKt1RJ$Tpphl3v#URE< MW=2NFB4!{90BaN;H~;_u literal 0 HcmV?d00001 diff --git a/app/domain/backoffice/__init__.py b/app/domain/backoffice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/domain/backoffice/__pycache__/__init__.cpython-313.pyc b/app/domain/backoffice/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9983ee42532e33a4b51f8adf3fba562abea37896 GIT binary patch literal 148 zcmey&%ge<81n*RpvOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~icer{fgeoA6V zqJCOtMSOBWntozIfqqJUZenJheo|s`c79q~W^$^2e0*kJW=VX!UP0w84x8Nkl+v73 XyCPPg86Z1~L5z>gjEsy$%s>_Z9aSQ4 literal 0 HcmV?d00001 diff --git a/app/examples/__init__.py b/app/examples/__init__.py new file mode 100644 index 0000000..3f95e96 --- /dev/null +++ b/app/examples/__init__.py @@ -0,0 +1 @@ +"""Exemplos de uso do template backend enterprise.""" diff --git a/app/examples/__pycache__/__init__.cpython-313.pyc b/app/examples/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eac1e1c6b326f1f1cbc57f74897e1a8326370455 GIT binary patch literal 201 zcmXv|Jqp4w7)?~P2)#ob>(ER(iJPm72l$B1SBbVwNK&ZY!$Wu+*W%^{gkHb|^eyiP z-s25vIwt|I=iH^?FhPc`+Hus5?RG@jhR;K0dZD?>NQ-_RvUST`;Z(Ncvaiu1L7Oy9648Kg zGa`(b8>H!b1B>45W?QEU3D0Q^S!3?`wGaU62K2!cDE98Xo3{j*VAvO&Nwm$4=dR zR{bWv{%qE}grL)N>Xp;_=j7E3^4c(;T2XA>(j+fS%KJdr8hqadexOvf5m>*0GN`KE zQdV&>TNUX$Z`;eYyUSwC4FfnRPGUa{oOQY-3{E+hf$xcI4A&6E(#<3%4PH@WBd{}; zmoh3vmy!ngYlLnRz>RXN!J4~RWZke8g#ldAZc1b%GY%w#(xCCK%H+v2$@*CbF6T@b z!YZ|bxa5(Tlve#s$BkA?B#KI>3zVRba~fFDmYAUf(w!lH&VT~qg=`YK=HTILAWzVj zsB2t)Y|K0|X1bY6zf8ZhQ`{~dU3>j_`qty=+TP6{m-d%B_2b*MZsEn9!gk@|?c+kJ zJ9Tv@yPbV_<9O=TZehMVF?*8JvqodUC5|jU?AnDF#AzP63>mcU{$p2>ox)WDV@@arzOhRxa zfgmR#$Vmu5P9d19yo?VPaHXgoT+_iXrbXIuywG(Vp~qX1A2gW!oe0(Q-B>6*W(l;( zckiM;q|ji8nKxRY*KX3c*aFPSpABz=^c0NoNlL@&(@}&cdwLG*-zz-?X7An3c*pHb z?N^iV=;KcZ)@uxGGx}YY^=3z~{@pu01ZGd&vpWlWpX`q&;gNlJU_WyQcCGnTma%zQ F`EO9Hn^6D& literal 0 HcmV?d00001 diff --git a/app/examples/__pycache__/ic_examples.cpython-313.pyc b/app/examples/__pycache__/ic_examples.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..026b8e0fb272813ba5b53ed191bd8535fae50aac GIT binary patch literal 1739 zcmdUvO>Y}T7{_P5FZQmJs(GPJ9MMeM24nX6$d0vy*UaMf)mfI<5ERbAa0Db&&xdjXLe@x zXCIA@8VJTu56`cyCPkul zjiuHUOS95fGFB#s))c1xjYbEJQPUEgVPz9tx|z<8w7-KTZf;V)<%JyADK3}r0xn0? z7fC4$A{KhIU>M~R$BecZZK}$fJKYZ)BVFBOtD96Ne{eh+&G(zSRCAhDHj_>qgt)2?$|Dld*l>f5&~>Qo)?fkpcfD zJV!r*xri`ab`>p2$JJ+?B~_)RXFHE;R_f}p`gQ5j@mzfj!Aj53%R~Kh>dHUV)u;88 zr4O13YW!61mDJ9r=8f~lyi~5ed!SmNDzrILz?g{A<8REpvkhrj0mHF`6Ej=39v(X z!F6_|Sa(9d6$a3GFYagv)2 zfYvw_t~=uF!=Xcvkk^CY|3+R_2KLl{$KDvQXO_%6g1pl0YWarA#Mw`Plvhtouor~r zj4-3ZoD$|V7+~-(hMtPJm;;b7n}8rbGvtFfBouTwUfTsYrl5Eh9=;0Z1ROK}1&*1Q zdgGIaSq=C&CHR;~_%H+?hTsFtkPmELmi8`4<|SqCq6Tq36Q^yv9y+!iYf-1=22Cb@ z4zW^qohX)h#1fceWhD%~Wa~TV2n1+w#>{JeSZ{mu4TfP({0ng(Okb8H=`f{8%99Z! zo$YIeq1OL&=ucF+~WkLE%AA$Qi>XvpVx^I8;S(5HA Tzd!IFl7VlmJrOcC3vc`rIMIje literal 0 HcmV?d00001 diff --git a/app/examples/__pycache__/mcp_examples.cpython-313.pyc b/app/examples/__pycache__/mcp_examples.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cd4702c48034162b1e63cc53206159a9586876f9 GIT binary patch literal 1881 zcmb^xO>Y}j@a?DVwd2Ni6ud6bO%qTT8XJ*TBtVg(q?Ho#MHXKm(rUZANjJ53w{O?M zj*1k)FW^u?PEEz794dtqJvMR=Jv#Ei)~bg}oO*KCKz3H#2YE z%p4C42ng2G!;#HDScHBhM7R5pvNXM6+WuhO$5#VXQkL3u4 zk%cg+G|Uq~LdH=7$XiI;d6kmy5w>))f_$ND`vpVuwZghpRdsj0pt)`#d}e{nm5|80 zwIE}j%qg`uI>_L`IWS1(rgsrK^Eui?07SkZ>8q;-}*)VS$??Ce1C3d z{+W<&3AsZd*N(paEH~B`-@GgCihCb5#X>v##@*O%Z10_BcDyZ~KZ8(*F6DICUsaH9lNu zii<5#X?&)H&Ze5;cuSnvPj_NyXsm;{Au!A(`JHQ>B#LLk__F^V-_x}C!`pjf_t|E8 z{3y;ttVt4UrW>n3ViicN1Y}Vny|Z|91>!y$21iFp;QsDSLe{>S9+}Ni2cy*N5PLAz zCF&eCJIvPk=?F+34^XpN_HnX{4^xDbC2&dRgNUjcj;^W!@7G+*-oT`B0@ko}KVV?u zcfaN*bPQaCMIwuSTy~5~$@~P5015wvye|NB7>c5fXqIAMBoOslhaaKR_a{0Cj)$Y& w=j$%7gU}3pjebF{V+qXv2^KxADgXcg literal 0 HcmV?d00001 diff --git a/app/examples/__pycache__/noc_examples.cpython-313.pyc b/app/examples/__pycache__/noc_examples.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5cc37961c29a9e1a37fbc1948e796609db8a6dd1 GIT binary patch literal 1828 zcmdUv&2Jk;6u@V_>$SbM(^8jEJN=lD0@(s~piQf&fMi9Lpa@sWVk9JsWx6|YH?en} znROaVfDrr*J>-;fBL^gUGbbAL!UZW+4mBeVNSu0eWhw+G-mJY8Ra9``!btn(y_vUf z-oE+m8|Cu`g7M?s*|i@ugnpJrJaQ9dbPbdn$U+QRij{aD$z zi6r<~$%ba>jii-=Cv9cUnMSiJf#>{uWhftTcXuH!2ICd$n zU0E(021v|khfz-bkl>KEXq$%2BRJ?#MjS8jNvn*n;OhZfUu^}~u^WIFL?I!?Yj?iD zwC#Z=qb)4F@{$Y)$|8=J&X@5e9#W2Je0DqFAtP-}H{nRgg}7dHjK}dtmtunXMwhs_ z6S&ln!950NfEhVE?9NxA=LEcL+?Rzk6`@xBo=68*Ic1xai6jppHIX76&+f98$V$7* zZ3p5BL#FLJJ-f|?&S=}&t9pZs$_QCI6gN?Es3R(eT&3)s2_8Ez^!)LJqJ88lHE z)sh#~RmIHIgu1ZssyOTfK2(j95N_IDr#)eC%DFT=S7e}A#1EyKCQZ05&`h1>;k($B z6o)_v9ZFq?N*m)!FUvw_CWU^7|DK~;1bCsm><7-SA~H_UhTDW_y9tq&H*CX(P@6O~ zlMH?r#N>@IU5b9I<;eNoBOBFACxZKE3Nbwzg_xf z>2~Ab;>w_Sa;Lamym|4USQ#8Uv6I=(+&pt|?B&7qi9zYPL2>RuK9w^nk(z8MM?nyFAdKa7BmGT%ib*hk|}OuyC++-{4$ z!Crth*_!xAAcm@@VLg^xwSoNo4N7_x^Zpu8rq_Ka_6lc}U<7MCGZI literal 0 HcmV?d00001 diff --git a/app/examples/__pycache__/observer_examples.cpython-313.pyc b/app/examples/__pycache__/observer_examples.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3c3b1aad79f229b4fbb6d4cc6cc74f5522ac34cd GIT binary patch literal 1337 zcmbtT&u+PNj_sv|G_Ng!Bi3R*GGV8iI{HO@&%Zt7Ug$mfE}9 znRSFYAVmLy9#GG{l~du=925cTTfOEYO1ljIzQKp)Slb?Ewah_Qk1sg2yU%!*pF)dz$a|1+lS#{nUQ|BPvp zDMx)(r5$9T#+R^`U===8*Reg_5U=}j1cT==@;Gw8rUPMe%DLQx!L6Bk)u~;rUB0r~ zbQ`q`&1$_V46j3@6i#i06`)2YU+k?Sz3KImF6B-(Z+HL&crQsDfxDQ^!ZfecS}PEz zv9c=i%?hctzNiVkx<)>4UbfYLB8md@!mjJb0kt)uc4#W{v=>5RUl{u!iZj$6q3;H6%9J-q4q$$HsKl5$pWIjh&rEKV{IM`VVOxpV`HkGXrz z?WOvii+`9iJLde* zoF5qrqoa#I4ek#%=eNtB4vpGq=J+^|rb=U^P3eCkEvEwlHI1fbcg)hzERBpeU(Q+h zH%{?I&SUb`&}dImy+GTQsdne2n$FZ;`~$es-IdoWOL(h@D<`$B8C&9WxZ-GA=ideV z=mf5u(jL97OYC5&Pf2a-BJa9E?7Obe(?JqO9VXi^v>^0Tq4JdNckVvg11n;0$TQ=7 zFAn-$`UzWrt^BF^X;5PoW4x=VSbJ_Fd~~eOV*ST6V+5ZE>1Oqp+0D-$-rnE-cI>&d IvJ$ZV2Kg*E4*&oF literal 0 HcmV?d00001 diff --git a/app/examples/grl_examples.py b/app/examples/grl_examples.py new file mode 100644 index 0000000..8dadac8 --- /dev/null +++ b/app/examples/grl_examples.py @@ -0,0 +1,37 @@ +"""Exemplos de GRL. + +GRL representa eventos de guardrails. Em regra, GRL.001..GRL.009 são emitidos +pelo pipeline de guardrails e pelo OutputSupervisor do framework. Use emissão +manual apenas para validações customizadas do agente. +""" + +from typing import Any + + +async def exemplo_guardrail_observado(observer: Any, state: dict[str, Any], rail_code: str, reason: str) -> None: + await observer.emit_grl( + "OBSERVE", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "rail_code": rail_code, + "reason": reason, + }, + component="examples.grl", + ) + + +async def exemplo_guardrail_block(observer: Any, state: dict[str, Any], rail_code: str, reason: str) -> None: + await observer.emit_grl( + "004", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "rail_code": rail_code, + "reason": reason, + "action": "block", + }, + component="examples.grl", + ) diff --git a/app/examples/ic_examples.py b/app/examples/ic_examples.py new file mode 100644 index 0000000..f6daa57 --- /dev/null +++ b/app/examples/ic_examples.py @@ -0,0 +1,34 @@ +"""Exemplos de IC - Item de Controle. + +ICs representam eventos de negócio. Eles alimentam Informacional, Curadoria, +analytics, BigQuery ou qualquer publisher configurado no framework. +""" + +from typing import Any + + +async def exemplo_fatura_consultada(observer: Any, state: dict[str, Any], invoice_id: str) -> None: + await observer.emit_ic( + "IC.FATURA_CONSULTADA", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "invoice_id": invoice_id, + }, + component="examples.ic", + ) + + +async def exemplo_acao_concluida(observer: Any, state: dict[str, Any], action_name: str, ok: bool) -> None: + await observer.emit_ic( + "IC.ACAO_CONCLUIDA", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "action_name": action_name, + "ok": ok, + }, + component="examples.ic", + ) diff --git a/app/examples/mcp_examples.py b/app/examples/mcp_examples.py new file mode 100644 index 0000000..613f10c --- /dev/null +++ b/app/examples/mcp_examples.py @@ -0,0 +1,43 @@ +"""Exemplos de MCP + IC. + +O AgentRuntimeMixin já possui _collect_mcp_context(), mas este arquivo mostra o +padrão para chamadas explícitas ao tool_router quando necessário. +""" + +from typing import Any + + +async def exemplo_chamada_mcp(tool_router: Any, observer: Any, state: dict[str, Any], tool_name: str, payload: dict[str, Any]) -> Any: + session_id = state.get("conversation_key") or state.get("session_id") + + await observer.emit_ic( + "IC.MCP_TOOL_CALLED", + { + "session_id": session_id, + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "tool_name": tool_name, + }, + component="examples.mcp", + ) + + result = await tool_router.call( + tool_name, + payload, + business_context=(state.get("context") or {}).get("business_context") or {}, + original_context=state.get("context") or {}, + ) + + await observer.emit_ic( + "IC.TOOL_CALLED", + { + "session_id": session_id, + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "tool_name": tool_name, + "ok": getattr(result, "ok", None), + }, + component="examples.mcp", + ) + + return result diff --git a/app/examples/noc_examples.py b/app/examples/noc_examples.py new file mode 100644 index 0000000..2b38a15 --- /dev/null +++ b/app/examples/noc_examples.py @@ -0,0 +1,37 @@ +"""Exemplos de NOC. + +NOC representa telemetria operacional. O workflow do template já emite NOC.001, +NOC.005 e NOC.006. Estes exemplos mostram eventos adicionais que a squad pode +emitir em pontos críticos. +""" + +from typing import Any + + +async def exemplo_api_invalida(observer: Any, state: dict[str, Any], api_url: str, status_code: int, latency_ms: int) -> None: + await observer.emit_noc( + "002", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "apiUrl": api_url, + "statusCode": status_code, + "latencyMs": latency_ms, + }, + component="examples.noc", + ) + + +async def exemplo_latencia_banco(observer: Any, state: dict[str, Any], resource_name: str, latency_ms: int) -> None: + await observer.emit_noc( + "003", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "resourceName": resource_name, + "latencyMs": latency_ms, + }, + component="examples.noc", + ) diff --git a/app/examples/observer_examples.py b/app/examples/observer_examples.py new file mode 100644 index 0000000..926b553 --- /dev/null +++ b/app/examples/observer_examples.py @@ -0,0 +1,28 @@ +"""Resumo prático do Observer corporativo. + +Use este arquivo como cola rápida para IC, NOC e GRL. +""" + +from typing import Any + + +async def emitir_eventos_basicos(observer: Any, state: dict[str, Any]) -> None: + session_id = state.get("conversation_key") or state.get("session_id") + + await observer.emit_ic( + "IC.EXEMPLO_NEGOCIO", + {"session_id": session_id, "agent_id": state.get("agent_id")}, + component="examples.observer", + ) + + await observer.emit_noc( + "EXEMPLO_OPERACIONAL", + {"session_id": session_id, "agent_id": state.get("agent_id")}, + component="examples.observer", + ) + + await observer.emit_grl( + "OBSERVE", + {"session_id": session_id, "agent_id": state.get("agent_id"), "rail_code": "CUSTOM"}, + component="examples.observer", + ) diff --git a/app/identity_extraction.py b/app/identity_extraction.py new file mode 100644 index 0000000..6a61f23 --- /dev/null +++ b/app/identity_extraction.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import re +from typing import Any + +_CPF_RE = re.compile(r"(?i)\bcpf\b\s*[:=\-]?\s*(\d{3}\.\d{3}\.\d{3}-\d{2}|\d{11})\b") +_CNPJ_RE = re.compile(r"(?i)\bcnpj\b\s*[:=\-]?\s*(\d{2}\.\d{3}\.\d{3}/\d{4}-\d{2}|\d{14})\b") +_MSISDN_RE = re.compile(r"(?i)\b(?:msisdn|linha|telefone|celular)\b\s*[:=\-]?\s*(\+?55)?\s*\(?\d{2}\)?\s*9?\d{4}[-\s]?\d{4}\b") +_PROTOCOL_RE = re.compile(r"(?i)\b(?:protocolo|protocol_id|chamado|reclama[cç][aã]o|ticket)\b\s*[:=\-#]?\s*([A-Za-z0-9][A-Za-z0-9._\-/]{3,})\b") + + +def _digits(value: str | None) -> str | None: + if not value: + return None + digits = re.sub(r"\D+", "", str(value)) + return digits or None + + +def extract_identity_from_text(text: str | None) -> dict[str, str]: + """Extrai chaves de negócio de mensagens livres do usuário. + + O IdentityResolver do framework mapeia campos estruturados. Esta função só + complementa payloads textuais como: "consultar dados do cliente cpf 123...". + """ + text = text or "" + found: dict[str, str] = {} + + cpf = _CPF_RE.search(text) + if cpf: + value = _digits(cpf.group(1)) + if value and len(value) == 11: + found["customer_key"] = value + found["cpf"] = value + found["document"] = value + found["document_type"] = "cpf" + + cnpj = _CNPJ_RE.search(text) + if cnpj: + value = _digits(cnpj.group(1)) + if value and len(value) == 14: + found["customer_key"] = value + found["cnpj"] = value + found["document"] = value + found["document_type"] = "cnpj" + + protocol = _PROTOCOL_RE.search(text) + if protocol: + value = protocol.group(1).strip() + if value and not value.lower().startswith(("cpf", "cnpj")): + found["interaction_key"] = value + found["protocol_id"] = value + found["protocolo"] = value + + # Só captura MSISDN quando há rótulo explícito para evitar confundir CPF/CNPJ. + msisdn = _MSISDN_RE.search(text) + if msisdn and "customer_key" not in found: + value = _digits(msisdn.group(0)) + if value: + # Remove prefixo 55 se o usuário digitou com DDI. + if value.startswith("55") and len(value) in {12, 13}: + value = value[2:] + found["customer_key"] = value + found["msisdn"] = value + found["document_type"] = "msisdn" + + return found + + +def enrich_payload_with_text_identity(payload: dict[str, Any] | None) -> dict[str, Any]: + payload = dict(payload or {}) + text = ( + payload.get("message") + or payload.get("text") + or payload.get("query") + or payload.get("content") + or "" + ) + extracted = extract_identity_from_text(str(text)) + # Payload estruturado sempre tem prioridade; extração só preenche lacunas. + for key, value in extracted.items(): + payload.setdefault(key, value) + return payload + + +def enrich_context_with_text_identity(context: dict[str, Any] | None, text: str | None) -> dict[str, Any]: + context = dict(context or {}) + extracted = extract_identity_from_text(text) + for key, value in extracted.items(): + context.setdefault(key, value) + return context diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..2d4ca57 --- /dev/null +++ b/app/main.py @@ -0,0 +1,826 @@ +from __future__ import annotations + +import logging +from uuid import uuid4 +import time + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from agent_framework.channels.base import ChannelResponse +from agent_framework.channels.gateway import ChannelGateway +from agent_framework.config.agent_registry import AgentProfileRegistry +from agent_framework.config.settings import settings +from agent_framework.analytics.factory import create_analytics_publisher +from agent_framework.observability.observer import AgentObserver +from src.compat.framework_observer import configure as configure_global_observer +from agent_framework.llm.providers import create_llm +from agent_framework.memory.message_history import create_memory +from agent_framework.mcp.tool_router import create_mcp_tool_router +from agent_framework.models.identity import AgentIdentity +from agent_framework.identity import IdentityResolver, BusinessContext +from agent_framework.models.session import ChatMessage, SessionContext +from agent_framework.observability.telemetry import Telemetry +from agent_framework.observability.context import set_observability_context, clear_observability_context +from agent_framework.repositories.session_repository import create_session_repository +from agent_framework.checkpoints.checkpoint_repository import create_checkpoint_repository +from agent_framework.cache.cache import create_cache +from agent_framework.billing.usage_repository import create_usage_repository +from agent_framework.sse.events import SSEHub +from app.workflows.agent_graph import AgentWorkflow +from app.workflows.backoffice_native_runtime import BackofficeNativeRuntime +from app.identity_extraction import enrich_payload_with_text_identity, extract_identity_from_text + +logging.basicConfig(level=settings.LOG_LEVEL) +logger = logging.getLogger("agent_template_backend") + +app = FastAPI(title="Agent Template Backend FIRST-ready") +app.add_middleware( + CORSMiddleware, + allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",")], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +telemetry = Telemetry(settings) +usage_repository = create_usage_repository(settings) +llm = create_llm(settings, telemetry=telemetry, usage_repository=usage_repository) +memory = create_memory(settings) +sessions = create_session_repository(settings) +checkpoints = create_checkpoint_repository(settings) +cache = create_cache(settings, telemetry=telemetry) +gateway = ChannelGateway() +analytics = create_analytics_publisher(settings) +observer = AgentObserver(analytics=analytics) +configure_global_observer({ + "enabled": getattr(settings, "ENABLE_ANALYTICS", False), + "providers": getattr(settings, "ANALYTICS_PROVIDERS", "oci_streaming"), + "topic_path": getattr(settings, "GCP_PUBSUB_TOPIC_PATH", None) or getattr(settings, "AGENT_PUBSUB_TOPIC", None), +}) +tool_router = create_mcp_tool_router(settings, telemetry=telemetry) +identity_resolver = IdentityResolver.from_yaml(settings.IDENTITY_CONFIG_PATH) +agent_profiles = AgentProfileRegistry(settings) +sse_hub = SSEHub(settings, telemetry=telemetry) +workflow = AgentWorkflow(llm, memory, telemetry, analytics, settings, observer=observer, tool_router=tool_router) +backoffice_runtime = BackofficeNativeRuntime(settings=settings, telemetry=telemetry, analytics=analytics, observer=observer) + +logger.info("LLM provider carregado: %s", llm.__class__.__name__) +logger.info("Langfuse habilitado: %s host=%s", telemetry.is_enabled(), settings.LANGFUSE_HOST) +logger.info("Analytics habilitado: %s providers=%s", getattr(settings, "ENABLE_ANALYTICS", False), getattr(settings, "ANALYTICS_PROVIDERS", "")) +logger.info("Agentes disponíveis: %s", [p.agent_id for p in agent_profiles.list_profiles()]) + +@app.middleware("http") +async def observability_context_middleware(request: Request, call_next): + request_id = request.headers.get("x-request-id") or str(uuid4()) + set_observability_context( + request_id=request_id, + channel=request.headers.get("x-channel") or "http", + ura_call_id=request.headers.get("x-ura-call-id"), + ) + started = time.time() + try: + response = await call_next(request) + response.headers["x-request-id"] = request_id + await telemetry.event("http.request.completed", { + "method": request.method, + "path": request.url.path, + "status_code": response.status_code, + "duration_ms": int((time.time() - started) * 1000), + }, kind="http") + return response + except Exception as exc: + await telemetry.event("http.request.failed", { + "method": request.method, + "path": request.url.path, + "error": str(exc), + "duration_ms": int((time.time() - started) * 1000), + }, kind="http") + raise + + +class GatewayRequest(BaseModel): + channel: str = "web" + payload: dict + agent_id: str | None = None + tenant_id: str | None = None + + +def _resolve_identity(req: GatewayRequest, msg) -> tuple[AgentIdentity, dict, BusinessContext, list[str]]: + payload = enrich_payload_with_text_identity(req.payload or {}) + context = dict(msg.context or {}) + tenant_id = req.tenant_id or payload.get("tenant_id") or context.get("tenant_id") or "default" + agent_id = req.agent_id or payload.get("agent_id") or context.get("agent_id") or agent_profiles.default_agent_id + profile = agent_profiles.get(agent_id) + + # 1) Identidade técnica do framework: isola tenant/agente/sessão. + context.update({"tenant_id": tenant_id, "agent_id": profile.agent_id, "agent_profile": profile.__dict__}) + identity = AgentIdentity.from_context(context, session_id=msg.session_id) + + # 2) Identidade de negócio: chaves canônicas vindas do front/canal. + # Correção importante: identidade extraída explicitamente do texto da + # mensagem atual (cpf/cnpj/protocolo) deve prevalecer sobre valores + # estáveis herdados da sessão, como msisdn default do frontend. + text_for_identity = str(payload.get("message") or payload.get("text") or payload.get("query") or msg.text or "") + explicit_identity = extract_identity_from_text(text_for_identity) + + previous_business_context = dict(context.get("business_context") or context.get("identity") or {}) + if explicit_identity.get("customer_key"): + previous_business_context.pop("customer_key", None) + if explicit_identity.get("interaction_key") or explicit_identity.get("protocol_id"): + previous_business_context.pop("interaction_key", None) + + resolver_payload = {**context, **payload} + # Garante que source business_context.customer_key não roube prioridade do + # CPF/CNPJ explícito. O IdentityResolver do framework preserva estabilidade, + # então inserimos a intenção explícita no próprio business_context da chamada. + if explicit_identity: + bc_override = dict(resolver_payload.get("business_context") or {}) + if explicit_identity.get("customer_key"): + bc_override["customer_key"] = explicit_identity["customer_key"] + if explicit_identity.get("interaction_key"): + bc_override["interaction_key"] = explicit_identity["interaction_key"] + if bc_override: + resolver_payload["business_context"] = bc_override + + business_context = identity_resolver.resolve( + resolver_payload, + session_id=identity.conversation_key(), + previous=previous_business_context, + ) + missing_identity_keys = identity_resolver.validate(business_context) + context.update({ + "business_context": business_context.model_dump(), + "business_keys": business_context.to_context_dict(), + "identity_missing": missing_identity_keys, + "conversation_key": identity.conversation_key(), + "original_session_id": msg.session_id, + }) + return identity, context, business_context, missing_identity_keys + + +async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False) -> dict: + msg = await gateway.normalize(req.channel, req.payload) + identity, normalized_context, business_context, missing_identity_keys = _resolve_identity(req, msg) + agent_session_id = identity.conversation_key() + message_id = (req.payload or {}).get("message_id") or str(uuid4()) + set_observability_context( + session_id=agent_session_id, + user_id=msg.user_id, + tenant_id=identity.tenant_id, + agent_id=identity.agent_id, + channel=msg.channel, + message_id=message_id, + ura_call_id=(req.payload or {}).get("ura_call_id") or normalized_context.get("ura_call_id") or business_context.interaction_key, + ) + + stream = sse_hub.stream_for(agent_session_id) + async with stream.lock: + await sse_hub.emit(agent_session_id, "flow.start", {"session_id": agent_session_id, "message_id": message_id, "agent_id": identity.agent_id}) if emit_sse else None + + session = await sessions.get(agent_session_id) + if not session: + context_fields = { + k: v + for k, v in normalized_context.items() + if k in SessionContext.model_fields + and k not in {"tenant_id", "agent_id", "session_id", "user_id", "channel", "channel_id"} + } + session = SessionContext( + tenant_id=identity.tenant_id, + agent_id=identity.agent_id, + session_id=agent_session_id, + user_id=msg.user_id, + channel=msg.channel, + channel_id=msg.channel_id, + **context_fields, + ) + + session.tenant_id = identity.tenant_id + session.agent_id = identity.agent_id + session.channel = msg.channel + session.channel_id = msg.channel_id or session.channel_id + await sessions.upsert(session) + session.metadata = { + **(session.metadata or {}), + "business_context": business_context.model_dump(), + "identity_missing": missing_identity_keys, + "original_context": normalized_context, + } + await sse_hub.emit(agent_session_id, "session.upserted", {"session_id": agent_session_id, "business_context": business_context.model_dump()}) if emit_sse else None + + await memory.append( + agent_session_id, + ChatMessage( + role="user", + content=msg.text, + metadata={ + **normalized_context, + "agent_id": identity.agent_id, + "tenant_id": identity.tenant_id, + "message_id": message_id, + "business_context": business_context.model_dump(), + "identity_missing": missing_identity_keys, + }, + ), + ) + await sse_hub.emit(agent_session_id, "message.received", {"session_id": agent_session_id, "role": "user"}) if emit_sse else None + history = [m.model_dump(mode="json") for m in await memory.list(agent_session_id)] + + trace_input = { + "text": msg.text, + "channel": msg.channel, + "channel_id": msg.channel_id, + "tenant_id": identity.tenant_id, + "agent_id": identity.agent_id, + "conversation_key": agent_session_id, + "message_id": message_id, + "business_context": business_context.model_dump(), + "identity_missing": missing_identity_keys, + } + + async with telemetry.span( + "agent.gateway_message", + session_id=agent_session_id, + user_id=session.user_id, + channel=msg.channel, + input=trace_input, + tags=["agent-template", msg.channel, f"agent:{identity.agent_id}", f"tenant:{identity.tenant_id}"], + ): + await telemetry.event("gateway.message.received", trace_input) + await sse_hub.emit(agent_session_id, "workflow.started", trace_input) if emit_sse else None + result = await workflow.ainvoke( + { + "tenant_id": identity.tenant_id, + "agent_id": identity.agent_id, + "session_id": agent_session_id, + "conversation_key": agent_session_id, + "agent_profile": normalized_context["agent_profile"], + "user_text": msg.text, + "history": history, + "context": { + **normalized_context, + "session": session.model_dump(mode="json"), + "original_session_id": msg.session_id, + "session_id": agent_session_id, + "conversation_key": agent_session_id, + "user_id": session.user_id, + "channel": msg.channel, + "message_id": message_id, + "business_context": business_context.model_dump(), + "business_keys": business_context.to_context_dict(), + "identity_missing": missing_identity_keys, + }, + } + ) + + await checkpoints.put(agent_session_id, {"state": result, "message_id": message_id}) + await sse_hub.emit(agent_session_id, "workflow.completed", {"session_id": agent_session_id, "route": result.get("route"), "intent": result.get("intent")}) if emit_sse else None + + answer = result.get("final_answer") or result.get("answer") or "" + await memory.append( + agent_session_id, + ChatMessage( + role="assistant", + content=answer, + metadata={ + "tenant_id": identity.tenant_id, + "agent_id": identity.agent_id, + "message_id": f"assistant-{message_id}", + "route": result.get("route"), + "intent": result.get("intent"), + "route_decision": result.get("route_decision"), + "judges": result.get("judge_results"), + }, + ), + ) + + await telemetry.event( + "gateway.message.responded", + { + "session_id": agent_session_id, + "tenant_id": identity.tenant_id, + "agent_id": identity.agent_id, + "route": result.get("route"), + "intent": result.get("intent"), + "answer_chars": len(answer), + }, + ) + + response = ChannelResponse( + channel=msg.channel, + session_id=agent_session_id, + text=answer, + metadata={ + "channel_id": msg.channel_id, + "tenant_id": identity.tenant_id, + "agent_id": identity.agent_id, + "original_session_id": msg.session_id, + "conversation_key": agent_session_id, + "message_id": message_id, + "route": result.get("route"), + "intent": result.get("intent"), + "route_decision": result.get("route_decision"), + "domain": result.get("domain"), + "mcp_tools": result.get("mcp_tools"), + "mcp_results": result.get("mcp_results"), + "business_context": business_context.model_dump(), + "identity_missing": missing_identity_keys, + "judges": result.get("judge_results"), + "guardrails": result.get("guardrail_decisions"), + }, + ) + rendered = await gateway.render(response) + await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None + await sse_hub.emit(agent_session_id, "flow.end", {"session_id": agent_session_id, "message_id": message_id}) if emit_sse else None + return rendered + + +@app.get("/health") +async def health(): + return { + "status": "ok", + "llm_provider": settings.LLM_PROVIDER, + "llm_class": llm.__class__.__name__, + "langfuse_enabled": telemetry.is_enabled(), + "agents": [p.agent_id for p in agent_profiles.list_profiles()], + "default_agent_id": agent_profiles.default_agent_id, + "routing_mode": settings.ROUTING_MODE, + "sse_enabled": settings.ENABLE_SSE, + "session_repository": settings.SESSION_REPOSITORY_PROVIDER, + "memory_repository": settings.MEMORY_REPOSITORY_PROVIDER, + "checkpoint_repository": settings.CHECKPOINT_REPOSITORY_PROVIDER, + "usage_repository": settings.USAGE_REPOSITORY_PROVIDER, + "identity_config_path": settings.IDENTITY_CONFIG_PATH, + "mcp_parameter_mapping_path": settings.MCP_PARAMETER_MAPPING_PATH, + } + + +@app.get("/agents") +async def list_agents(): + return {"default_agent_id": agent_profiles.default_agent_id, "agents": [p.__dict__ for p in agent_profiles.list_profiles()]} + + +@app.get("/debug/env") +async def debug_env(): + return { + "APP_ENV": settings.APP_ENV, + "LLM_PROVIDER": settings.LLM_PROVIDER, + "ENABLE_LANGFUSE": settings.ENABLE_LANGFUSE, + "LANGFUSE_HOST": settings.LANGFUSE_HOST, + "TELEMETRY_ENABLED": telemetry.is_enabled(), + "SQLITE_DB_PATH": settings.SQLITE_DB_PATH, + "SESSION_REPOSITORY_PROVIDER": settings.SESSION_REPOSITORY_PROVIDER, + "MEMORY_REPOSITORY_PROVIDER": settings.MEMORY_REPOSITORY_PROVIDER, + "CHECKPOINT_REPOSITORY_PROVIDER": settings.CHECKPOINT_REPOSITORY_PROVIDER, + "AGENTS_CONFIG_PATH": settings.AGENTS_CONFIG_PATH, + "ROUTING_CONFIG_PATH": settings.ROUTING_CONFIG_PATH, + "ROUTING_MODE": settings.ROUTING_MODE, + } + + +@app.get("/test-llm") +async def test_llm(): + async with telemetry.span("debug.test_llm", input={"message": "Diga apenas OK"}): + answer = await llm.ainvoke([ + {"role": "system", "content": "Responda de forma curta."}, + {"role": "user", "content": "Diga apenas OK"}, + ]) + telemetry.flush() + return {"provider": llm.__class__.__name__, "answer": answer} + + +@app.post("/debug/route") +async def debug_route(req: GatewayRequest): + msg = await gateway.normalize(req.channel, req.payload) + identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg) + state = { + "tenant_id": identity.tenant_id, + "agent_id": identity.agent_id, + "session_id": msg.session_id or "debug-session", + "conversation_key": identity.conversation_key(), + "agent_profile": context["agent_profile"], + "user_text": msg.text, + "sanitized_input": msg.text, + "history": [], + "context": {**context, "session": context.get("session", {}), "channel": msg.channel, "business_context": business_context.model_dump()}, + } + if settings.ROUTING_MODE == "supervisor": + plan = await workflow.supervisor.route_plan(state) + return {"mode": "supervisor", "route": "supervisor_agent", "agents": plan.agents, "intent": plan.intent, "confidence": plan.confidence, "reason": plan.reason, "metadata": plan.metadata} + decision = await workflow.router.route(state) + data = decision.model_dump(mode="json") + data["mode"] = "router" + return data + + + + +@app.post("/debug/identity") +async def debug_identity(req: GatewayRequest): + msg = await gateway.normalize(req.channel, req.payload) + identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg) + return { + "technical_identity": { + "tenant_id": identity.tenant_id, + "agent_id": identity.agent_id, + "conversation_key": identity.conversation_key(), + "original_session_id": msg.session_id, + }, + "business_context": business_context.model_dump(), + "identity_missing": missing_identity_keys, + "context_keys": sorted(context.keys()), + } + +@app.get("/debug/usage") +async def debug_usage(tenant_id: str | None = None, session_id: str | None = None): + return await usage_repository.summarize(tenant_id=tenant_id, session_id=session_id) + + +@app.get("/debug/mcp/tools") +async def debug_mcp_tools(): + return {"enabled": tool_router.enabled, "tools": tool_router.describe_tools()} + + +@app.post("/debug/mcp/call/{tool_name}") +async def debug_mcp_call(tool_name: str, arguments: dict | None = None): + arguments = arguments or {} + ctx = arguments.get("business_context") or arguments.get("identity") or {} + result = await tool_router.call( + tool_name, + arguments, + business_context=ctx, + original_context=arguments, + ) + return result.model_dump(mode="json") + + +@app.post("/gateway/message") +async def gateway_message(req: GatewayRequest): + return await _process_gateway_message(req, emit_sse=False) + + +@app.post("/gateway/message/sse") +async def gateway_message_sse(req: GatewayRequest): + return await _process_gateway_message(req, emit_sse=True) + + +@app.get("/gateway/events/{session_id}") +async def gateway_events(session_id: str, request: Request): + last = request.headers.get("last-event-id") or request.query_params.get("last_event_id") or "0" + return StreamingResponse( + sse_hub.subscribe(session_id, int(last)), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, + ) + + +@app.get("/sessions/{session_id}/messages") +async def get_session_messages(session_id: str, limit: int = 50): + return {"session_id": session_id, "messages": [m.model_dump(mode="json") for m in await memory.list(session_id, limit)]} + + +@app.get("/sessions/{session_id}/checkpoint") +async def get_session_checkpoint(session_id: str): + return {"session_id": session_id, "checkpoint": await checkpoints.get_latest(session_id)} + + +@app.on_event("shutdown") +async def shutdown(): + telemetry.shutdown() + + + +# --------------------------------------------------------------------------- +# Backoffice TIM/ANATEL develop — execução 100% framework-native +# --------------------------------------------------------------------------- +# As rotas antigas permanecem como adapters REST, mas não registram routers +# legados e não executam legacy graph package nem legacy executor package. +# Elas chamam o BackofficeNativeRuntime, que compila os workflows com o motor +# do framework e aplica guardrails, judges, supervisor, checkpoint e telemetry. + +import asyncio +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from fastapi import status, HTTPException + + +@app.on_event("startup") +async def startup_backoffice_native_domain(): + """Inicializa apenas recursos de domínio; o motor de workflow é do framework.""" + try: + from src.utils.observer import setup_observer + setup_observer() + except Exception: + logger.warning("Observer de domínio não pôde ser inicializado", exc_info=True) + + try: + from src.infrastructure.oci.autonomous import db_manager as original_db_manager + app.state.original_db_manager = original_db_manager + await original_db_manager.connect() + logger.info("Autonomous/Mongo manager de domínio conectado") + except Exception: + logger.warning("DB de domínio indisponível; fluxos continuam quando os nós suportarem fallback/mock", exc_info=True) + + # Compatibilidade para nós que esperam app.state.oci_producer; a criação real + # continua opcional e não controla o grafo. + try: + from src.core.config import settings as original_settings + if getattr(original_settings, "ENABLE_OCI_STREAMING", False): + from src.infrastructure.streaming.producer import OciProducer + app.state.oci_producer = OciProducer(original_settings.OCI_RESPONSE_STREAM_OCID) + logger.info("OCI producer de domínio inicializado; consumer legado não é iniciado") + else: + try: + from src.infrastructure.streaming.debug_producer import LocalDebugProducer + if getattr(original_settings, "DEBUG", False): + app.state.oci_producer = LocalDebugProducer() + logger.info("LocalDebugProducer de domínio ativo") + except Exception: + pass + except Exception: + logger.warning("Producer de domínio não inicializado", exc_info=True) + + +@app.on_event("shutdown") +async def shutdown_backoffice_native_domain(): + try: + dbm = getattr(app.state, "original_db_manager", None) + if dbm is not None: + await dbm.close() + except Exception: + logger.warning("Falha ao encerrar DB de domínio", exc_info=True) + + +@app.exception_handler(RequestValidationError) +async def native_validation_exception_handler(request: Request, exc: RequestValidationError): + """Preserva o formato de erro ANATEL sem ativar rotas/executors legados.""" + try: + from src.api.schemas.anatel_schemas import ERROR_CODE_MAPPING, ReasonCode + error_messages = [] + for err in exc.errors(): + loc_tuple = tuple(l for l in err.get("loc", []) if l != "body") + 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: + 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", [])]) + reason_text = f"{loc_str}: {err.get('msg', 'Invalid field')}" + error_messages.append({"code": reason_code.value, "text": reason_text}) + try: + body = await request.json() + correlation_id = body.get("transactionId") or body.get("correlation_id") or "unknown" + except Exception: + correlation_id = "unknown" + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"title": "validation error", "status": 400, "correlation_id": correlation_id, "detail": {"messages": error_messages}}, + ) + except Exception: + return JSONResponse(status_code=422, content={"detail": exc.errors()}) + + +async def _run_native_checklist(event, request: Request): + from src.api.utils import agent_helpers + transaction_id = event.transactionId or f"man-{uuid4().hex[:8]}" + payload = event.model_dump(mode="json", by_alias=True) + final_state = await backoffice_runtime.execute_workflow( + "backoffice_checklist", + payload=payload, + transaction_id=transaction_id, + app_state=request.app.state, + metadata={ + "tenant_id": "default", + "agent_id": "backoffice_anatel", + "channel": "rest", + "legacy_contract": "agent.process-ticket", + }, + ) + try: + response_event = agent_helpers.build_cms_response_event(final_state, transaction_id) + return response_event.model_dump(mode="json", by_alias=True) + except Exception: + return { + "transactionId": transaction_id, + "framework_native": True, + "current_step": str(final_state.get("current_step")), + "final_response": final_state.get("final_response"), + "error": final_state.get("error"), + "metadata": final_state.get("metadata", {}), + } + + +@app.post("/agent/process-ticket", status_code=status.HTTP_200_OK) +async def native_process_ticket(request: Request, event: "TicketRequestEvent"): + return await _run_native_checklist(event, request) + + +@app.post("/agent/execute", status_code=status.HTTP_200_OK) +async def native_agent_execute(request: Request, event: "TicketRequestEvent"): + return await _run_native_checklist(event, request) + + +@app.post("/agent/process-and-stream", status_code=status.HTTP_200_OK) +async def native_process_and_stream(request: Request, event: "TicketRequestEvent"): + return await _run_native_checklist(event, request) + + +@app.post("/agent/search-tais-kb", status_code=status.HTTP_200_OK) +async def native_search_tais_kb(body: dict): + """Adapter REST para TAIS KB usando o service/cliente de domínio, não grafo legado.""" + try: + from src.components.clients.tais_kb_client import TaisKbClient + query = body.get("query") or body.get("text") or body.get("complaint_description") or "" + client = TaisKbClient() + if hasattr(client, "search"): + result = await client.search(query=query, **{k: v for k, v in body.items() if k not in {"query", "text", "complaint_description"}}) + elif hasattr(client, "query"): + result = await client.query(query) + else: + raise AttributeError("TAIS KB client has no search/query method") + return {"framework_native": True, "result": result} + except Exception as exc: + await telemetry.event("backoffice.tais_kb.failed", {"error": str(exc)}, kind="tool") + return {"framework_native": True, "result": [], "error": str(exc)} + + +async def _run_native_emulator(request: Request, event): + transaction_id = event.transactionId + payload = event.model_dump(mode="json", by_alias=True) + final_state = await backoffice_runtime.execute_workflow( + "backoffice_response_emulator", + payload=payload, + transaction_id=transaction_id, + app_state=request.app.state, + metadata={ + "tenant_id": "default", + "agent_id": "backoffice_anatel", + "channel": "rest", + "flow_mode": event.flow_mode, + "selected_actions": [a.model_dump(mode="json") for a in event.selected_actions], + "legacy_contract": "case.response-emulator", + }, + ) + try: + from src.api.utils.emulator_response_builder import build_emulator_response_event + response_event = build_emulator_response_event(final_state, transaction_id) + return response_event.model_dump(mode="json", by_alias=True) + except Exception: + return { + "transactionId": transaction_id, + "framework_native": True, + "current_step": str(final_state.get("current_step")), + "final_response": final_state.get("final_response"), + "error": final_state.get("error"), + "metadata": final_state.get("metadata", {}), + } + + +@app.post("/case/{transaction_id}/response-emulator/generate", status_code=status.HTTP_200_OK) +async def native_response_emulator_generate(transaction_id: str, request: Request, body: "EmulatorGenerateRequest"): + from src.api.schemas.anatel_response_emulator_schemas import ResponseEmulatorRequestEvent, OperatorFeedback + if body.transactionId != transaction_id: + raise HTTPException(status_code=400, detail="transactionId path/body mismatch") + if body.action == "generate": + event = ResponseEmulatorRequestEvent( + transactionId=transaction_id, + type="first_response", + flow_mode="generate", + selected_actions=body.selected_actions or [], + ) + else: + event = ResponseEmulatorRequestEvent( + transactionId=transaction_id, + type="regenerate", + flow_mode="generate", + selected_actions=[], + previous_response="", + feedback=OperatorFeedback(comment=body.operator_instructions or ""), + ) + return await _run_native_emulator(request, event) + + +@app.post("/case/{transaction_id}/response-emulator/finalize", status_code=status.HTTP_200_OK) +async def native_response_emulator_finalize(transaction_id: str, request: Request, body: "EmulatorFinalizeRequest"): + from src.api.schemas.anatel_response_emulator_schemas import ResponseEmulatorRequestEvent + if body.transactionId != transaction_id: + raise HTTPException(status_code=400, detail="transactionId path/body mismatch") + event = ResponseEmulatorRequestEvent( + transactionId=transaction_id, + type="first_response", + flow_mode=body.action, + selected_actions=[], + ) + return await _run_native_emulator(request, event) + + +@app.get("/case/{transaction_id}/response-emulator", status_code=status.HTTP_200_OK) +async def native_response_emulator_status(transaction_id: str): + """Status reader nativo. Lê DB de domínio quando disponível, sem rota legada.""" + try: + from src.core.config import settings as original_settings + dbm = getattr(app.state, "original_db_manager", None) + db = getattr(dbm, "db", None) + if db is None: + return {"transactionId": transaction_id, "framework_native": True, "status": None, "detail": "domain DB unavailable"} + coll = db[original_settings.AUTONOMOUS_NOSQL_COLLECTION] + doc = await coll.find_one({"transactionId": transaction_id}) + if not doc: + return {"transactionId": transaction_id, "framework_native": True, "status": None} + processing = doc.get("processing") or {} + return { + "transactionId": transaction_id, + "framework_native": True, + "status": processing.get("status"), + "current_step": processing.get("current_step"), + "case_response": processing.get("case_response") or doc.get("case_response"), + "validation": (doc.get("metadata") or {}).get("validation"), + "selected_actions_count": len((doc.get("metadata") or {}).get("selected_actions") or []), + "transitions": doc.get("transitions") or [], + "last_updated_at": processing.get("updated_at") or processing.get("timestamp"), + } + except Exception as exc: + return {"transactionId": transaction_id, "framework_native": True, "status": None, "error": str(exc)} + + +@app.post("/emulator-rag/search", status_code=status.HTTP_200_OK) +async def native_emulator_rag_search(body: dict): + try: + from src.agent.nodes.emulator._rag_query import query_emulator_rag + result = await query_emulator_rag(body) + return {"framework_native": True, "result": result} + except Exception as exc: + return {"framework_native": True, "result": [], "error": str(exc)} + + +@app.get("/health/live") +async def native_health_live(): + return {"status": "live", "framework_native": True} + + +@app.get("/health/ready") +async def native_health_ready(): + return { + "status": "ready", + "framework_native": True, + "workflows": list(backoffice_runtime._graphs.keys()), + "framework_layers": { + "gateway": True, + "identity": True, + "session_repository": settings.SESSION_REPOSITORY_PROVIDER, + "memory_repository": settings.MEMORY_REPOSITORY_PROVIDER, + "checkpoint_repository": settings.CHECKPOINT_REPOSITORY_PROVIDER, + "guardrails": True, + "judges": True, + "supervisor": True, + "mcp_router": tool_router.enabled, + "telemetry": telemetry.is_enabled(), + }, + } + + +@app.get("/debug/backoffice/parity") +async def debug_backoffice_parity(): + return { + "mode": "framework_native_domain_workflows", + "legacy_graph_execution": False, + "legacy_router_registration": False, + "forbidden_active_imports": ["legacy_reference_disabled/original_develop/src_agent_graphs", "legacy_reference_disabled/original_develop/src_api_executors"], + "runtime": "app.workflows.backoffice_native_runtime.BackofficeNativeRuntime", + "domain_package": "src.agent.nodes + src.components.clients + src.agent.local_prompts", + "workflows": { + "backoffice_checklist": [ + "framework_input_guardrails", "fetch_ticket", "validation", "bypass_rules", "cache_check", "imdb_enrichment", "identity_verification", "speech_enrichment", "knowledge_base_enrichment", "canceling_analysis", "tim_complaint_analysis", "operator_route", "reclassification_analysis", "treatment_decision", "siebel_sr_opening", "framework_output_supervisor", "framework_output_guardrails", "framework_judges", "framework_supervisor_review", "framework_persist" + ], + "backoffice_response_emulator": [ + "framework_input_guardrails", "start_response_emulation", "fetch_case", "validate_actions", "router", "retrieve_templates", "retrieve_history", "generate_response", "validate_response", "persist_draft", "approve_draft", "close_case", "framework_output_supervisor", "framework_output_guardrails", "framework_judges", "framework_supervisor_review", "framework_persist" + ], + }, + "framework_layers": { + "gateway": True, + "identity": True, + "session_repository": settings.SESSION_REPOSITORY_PROVIDER, + "memory_repository": settings.MEMORY_REPOSITORY_PROVIDER, + "checkpoint_repository": settings.CHECKPOINT_REPOSITORY_PROVIDER, + "guardrails": True, + "judges": True, + "supervisor": True, + "mcp_router": tool_router.enabled, + "telemetry": telemetry.is_enabled(), + }, + } + +# Late imports only for FastAPI annotation resolution. They are schemas, not +# workflow motors. +from src.api.schemas.anatel_schemas import TicketRequestEvent +from src.api.schemas.anatel_response_emulator_schemas import EmulatorGenerateRequest, EmulatorFinalizeRequest diff --git a/app/state.py b/app/state.py new file mode 100644 index 0000000..3620e4e --- /dev/null +++ b/app/state.py @@ -0,0 +1,34 @@ +from typing import Any, TypedDict + + +class AgentState(TypedDict, total=False): + tenant_id: str + agent_id: str + session_id: str + conversation_key: str + agent_profile: dict[str, Any] + user_text: str + sanitized_input: str + route: str + intent: str + route_decision: dict[str, Any] + answer: str + final_answer: str + history: list[dict[str, Any]] + context: dict[str, Any] + guardrail_decisions: list[dict[str, Any]] + judge_results: list[dict[str, Any]] + next_state: str + domain: str + mcp_tools: list[str] + mcp_results: list[dict[str, Any]] + supervisor_plan: dict[str, Any] + supervisor_results: list[dict[str, Any]] + active_agent: str + blocked: bool + supervisor_action: str + supervisor_guidance: str + supervisor_attempt: int + supervisor_handover_reason: str + output_supervisor_results: list[dict[str, Any]] + output_guardrails_already_applied: bool diff --git a/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/app/workflows/__pycache__/agent_graph.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e0318af428e45a1d6e67c8652099bcd00d64a813 GIT binary patch literal 46274 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 Ow~5btz!79a!~X(Q=eEZH literal 0 HcmV?d00001 diff --git a/app/workflows/__pycache__/backoffice_native_runtime.cpython-313.pyc b/app/workflows/__pycache__/backoffice_native_runtime.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c39919f486b3e03419fd229a9798dc187ed1b26b GIT binary patch literal 59884 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 routing_decision/EnterpriseRouter -> 1 agente -> output_guardrails + + ROUTING_MODE=supervisor + input_guardrails -> routing_decision/Supervisor -> supervisor_agent -> N agentes -> consolidação + + Em ambos os modos, memória/checkpoint/session usam tenant_id:agent_id:session_id. + """ + + def __init__(self, llm, memory, telemetry, analytics, settings, observer: AgentObserver | None = None, tool_router=None): + self.llm = llm + self.memory = memory + self.telemetry = telemetry + self.analytics = analytics + self.observer = observer or AgentObserver(analytics=analytics) + self.settings = settings + self.tool_router = tool_router + self.tool_router = tool_router + self.guardrails = GuardrailPipeline( + observer=self.observer, + enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)), + fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)), + ) + self.output_supervisor_engine = OutputSupervisor( + rails=[LegacyOutputGuardrailRail(self.guardrails)], + observer=self.observer, + max_retries=int(getattr(settings, "OUTPUT_SUPERVISOR_MAX_RETRIES", 3)), + enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)), + fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)), + ) + self.judges = JudgePipeline() + self.supervisor = Supervisor() + self.workflow_telemetry = WorkflowTelemetry(telemetry) + self.guardrail_telemetry = GuardrailTelemetry(telemetry) + self.judge_telemetry = JudgeTelemetry(telemetry) + self.langgraph_telemetry = LangGraphDeepTelemetry(telemetry) + self.cache = create_cache(settings) + self.rag_service = RagService(settings, telemetry=telemetry) + self.router = EnterpriseRouter(settings, llm=llm, telemetry=telemetry) + agent_kwargs = {"telemetry": telemetry, "tool_router": getattr(self, "tool_router", None), "rag_service": self.rag_service, "cache": self.cache, "settings": settings, "observer": self.observer} + self.billing = BillingAgent(llm, **agent_kwargs) + self.product = ProductAgent(llm, **agent_kwargs) + self.orders = OrdersAgent(llm, **agent_kwargs) + self.support = SupportAgent(llm, **agent_kwargs) + self.backoffice = BackofficeAgent(llm, **agent_kwargs) + self.graph = self._build_graph() + + def _node(self, name, fn): + async def _wrapped(state): + async with self.langgraph_telemetry.node(name, state): + return await fn(state) + return _wrapped + + def _build_graph(self): + builder = StateGraph(AgentState) + builder.add_node("input_guardrails", self._node("input_guardrails", self.input_guardrails)) + builder.add_node("routing_decision", self._node("routing_decision", self.routing_decision)) + builder.add_node("billing_agent", self._node("billing_agent", self.billing_agent)) + builder.add_node("product_agent", self._node("product_agent", self.product_agent)) + builder.add_node("orders_agent", self._node("orders_agent", self.orders_agent)) + builder.add_node("support_agent", self._node("support_agent", self.support_agent)) + builder.add_node("backoffice_agent", self._node("backoffice_agent", self.backoffice_agent)) + builder.add_node("handoff", self._node("handoff", self.handoff)) + builder.add_node("supervisor_agent", self._node("supervisor_agent", self.supervisor_agent)) + builder.add_node("output_supervisor", self._node("output_supervisor", self.output_supervisor)) + builder.add_node("output_guardrails", self._node("output_guardrails", self.output_guardrails)) + builder.add_node("judge", self._node("judge", self.judge)) + builder.add_node("supervisor_review", self._node("supervisor_review", self.supervisor_review)) + builder.add_node("persist", self._node("persist", self.persist)) + + builder.add_edge(START, "input_guardrails") + builder.add_conditional_edges( + "input_guardrails", + self._after_input_guardrails, + {"blocked": "persist", "continue": "routing_decision"}, + ) + builder.add_conditional_edges( + "routing_decision", + lambda s: s.get("route", "billing_agent"), + { + "billing_agent": "billing_agent", + "product_agent": "product_agent", + "orders_agent": "orders_agent", + "support_agent": "support_agent", + "backoffice_agent": "backoffice_agent", + "handoff": "handoff", + "supervisor_agent": "supervisor_agent", + }, + ) + builder.add_edge("billing_agent", "output_supervisor") + builder.add_edge("product_agent", "output_supervisor") + builder.add_edge("orders_agent", "output_supervisor") + builder.add_edge("support_agent", "output_supervisor") + builder.add_edge("backoffice_agent", "output_supervisor") + builder.add_edge("handoff", "output_supervisor") + builder.add_edge("supervisor_agent", "output_supervisor") + builder.add_edge("output_supervisor", "output_guardrails") + builder.add_edge("output_guardrails", "judge") + builder.add_edge("judge", "supervisor_review") + builder.add_edge("supervisor_review", "persist") + builder.add_edge("persist", END) + + return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings)) + + def _after_input_guardrails(self, state): + return "blocked" if state.get("blocked") else "continue" + + async def input_guardrails(self, state): + async with self.telemetry.span( + "workflow.input_guardrails", + session_id=state.get("conversation_key") or state.get("session_id"), + input=state.get("user_text"), + ): + history_texts = [m.get("content", "") for m in state.get("history", [])] + await self.observer.emit_grl( + "001", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "phase": "input", + }, + component="workflow.input_guardrails.start", + ) + sanitized, decisions = await self.guardrails.run_input( + state["user_text"], + { + **(state.get("context") or {}), + "history_texts": history_texts, + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "agent_profile": state.get("agent_profile") or {}, + }, + ) + for _decision in decisions: + await self.guardrail_telemetry.evaluated("input", _decision) + await self.observer.emit_grl( + "002" if _decision.allowed else "004", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "phase": "input", + "rail_code": getattr(_decision, "code", None), + "allowed": bool(_decision.allowed), + "reason": getattr(_decision, "reason", None), + }, + component="workflow.input_guardrails.decision", + ) + if not _decision.allowed: + await self.guardrail_telemetry.blocked("input", _decision) + await self.telemetry.event( + "guardrails.input.completed", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "decisions": [d.model_dump() for d in decisions], + }, + ) + await self.observer.emit_grl( + "009", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "phase": "input", + "blocked": any(not d.allowed for d in decisions), + "decision_count": len(decisions), + }, + component="workflow.input_guardrails.final", + ) + if any(not d.allowed for d in decisions): + return { + "sanitized_input": sanitized, + "answer": "Não consegui seguir com essa mensagem por regra de segurança.", + "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", + "guardrail_decisions": [d.model_dump() for d in decisions], + "route": "blocked", + "blocked": True, + } + return { + "sanitized_input": sanitized, + "guardrail_decisions": [d.model_dump() for d in decisions], + "blocked": False, + } + + async def routing_decision(self, state): + mode = getattr(self.settings, "ROUTING_MODE", "router") + async with self.telemetry.span( + "workflow.routing_decision", + session_id=state.get("conversation_key") or state.get("session_id"), + input={ + "mode": mode, + "text": state.get("sanitized_input") or state.get("user_text"), + "previous_state": state.get("next_state"), + }, + ): + if mode == "supervisor": + plan = await self.supervisor.route_plan(state) + await self.langgraph_telemetry.edge("routing_decision", "supervisor_agent", state, {"method": "supervisor", "intent": plan.intent, "confidence": plan.confidence}) + return { + "route": "supervisor_agent", + "intent": plan.intent, + "supervisor_plan": { + "agents": plan.agents, + "intent": plan.intent, + "confidence": plan.confidence, + "reason": plan.reason, + "metadata": plan.metadata, + }, + "route_decision": { + "route": "supervisor_agent", + "agent": "supervisor", + "intent": plan.intent, + "confidence": plan.confidence, + "reason": plan.reason, + "method": "supervisor", + "metadata": plan.metadata, + }, + } + + decision = await self.router.route(state) + await self.langgraph_telemetry.edge("routing_decision", decision.route, state, {"method": getattr(decision, "method", None), "intent": decision.intent, "confidence": decision.confidence}) + await self.observer.emit_ic( + "ROUTE_SELECTED", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "route": decision.route, + "intent": decision.intent, + "confidence": decision.confidence, + "method": getattr(decision, "method", None), + }, + component="workflow.routing_decision", + ) + return { + "route": decision.route, + "intent": decision.intent, + "route_decision": decision.model_dump(mode="json"), + "domain": decision.domain, + "mcp_tools": decision.mcp_tools, + "next_state": decision.next_state, + } + + async def billing_agent(self, state): + async with self.langgraph_telemetry.node("billing_agent", state): + async with self.telemetry.span( + "workflow.agent.billing", + session_id=state.get("conversation_key") or state.get("session_id"), + input={"intent": state.get("intent")}, + ): + return await self.billing.run(state) + + async def product_agent(self, state): + async with self.langgraph_telemetry.node("product_agent", state): + async with self.telemetry.span( + "workflow.agent.product", + session_id=state.get("conversation_key") or state.get("session_id"), + input={"intent": state.get("intent")}, + ): + return await self.product.run(state) + + async def orders_agent(self, state): + async with self.langgraph_telemetry.node("orders_agent", state): + async with self.telemetry.span( + "workflow.agent.orders", + session_id=state.get("conversation_key") or state.get("session_id"), + input={"intent": state.get("intent")}, + ): + return await self.orders.run(state) + + + async def support_agent(self, state): + async with self.langgraph_telemetry.node("support_agent", state): + async with self.telemetry.span( + "workflow.agent.support", + session_id=state.get("conversation_key") or state.get("session_id"), + input={"intent": state.get("intent")}, + ): + return await self.support.run(state) + + async def backoffice_agent(self, state): + async with self.langgraph_telemetry.node("backoffice_agent", state): + async with self.telemetry.span( + "workflow.agent.backoffice", + session_id=state.get("conversation_key") or state.get("session_id"), + input={"intent": state.get("intent")}, + ): + return await self.backoffice.run(state) + + async def supervisor_agent(self, state): + """Executa um ou mais agentes no modo supervisor e consolida a resposta. + + Este nó mantém o desenho de supervisor sem obrigar o restante do workflow + a conhecer quantos agentes foram acionados. Cada execução especializada + recebe o mesmo estado, mas com route/active_agent atualizados. + """ + plan = state.get("supervisor_plan") or {} + agents = plan.get("agents") or ["backoffice_agent"] + handlers = { + "billing_agent": self.billing.run, + "product_agent": self.product.run, + "orders_agent": self.orders.run, + "support_agent": self.support.run, + "backoffice_agent": self.backoffice.run, + } + partials = [] + mcp_results = [] + async with self.telemetry.span( + "workflow.supervisor_agent", + session_id=state.get("conversation_key") or state.get("session_id"), + input={"agents": agents, "intent": state.get("intent")}, + ): + for agent_name in agents: + handler = handlers.get(agent_name) + if handler is None: + continue + child_state = {**state, "route": agent_name, "active_agent": agent_name} + result = await handler(child_state) + partials.append({"agent": agent_name, "answer": result.get("answer", "")}) + mcp_results.extend(result.get("mcp_results") or []) + + if len(partials) == 1: + answer = partials[0]["answer"] + else: + joined = "\n\n".join(f"{p['agent']}: {p['answer']}" for p in partials) + answer = ( + "[Supervisor] Consolidação de múltiplos agentes acionados.\n" + f"{joined}" + ) + return { + "answer": answer, + "supervisor_results": partials, + "mcp_results": mcp_results, + "next_state": "SUPERVISOR_ACTIVE", + } + + async def handoff(self, state): + async with self.telemetry.span("workflow.handoff", session_id=state.get("session_id")): + target = (state.get("route_decision") or {}).get("metadata", {}).get("target_agent") + answer = ( + "Vou redirecionar sua solicitação para o especialista correto. " + f"Destino sugerido: {target or 'agente especializado'}." + ) + return {"answer": answer} + + async def output_supervisor(self, state): + """Valida a resposta candidata com o OutputSupervisor corporativo. + + Este nó não substitui o roteador/supervisor multiagente. Ele roda após o + agente gerar `answer` e antes dos judges/persistência, produzindo campos + supervisor_* no state e eventos GRL.001..GRL.009 via AgentObserver. + """ + if not bool(getattr(self.settings, "ENABLE_OUTPUT_SUPERVISOR", True)): + return { + "output_guardrails_already_applied": False, + "supervisor_action": "disabled", + "supervisor_attempt": int(state.get("supervisor_attempt", 0)), + } + + candidate = state.get("answer") or "" + context = { + **(state.get("context") or {}), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "session_id": state.get("conversation_key") or state.get("session_id"), + "route": state.get("route"), + "intent": state.get("intent"), + "supervisor_attempt": int(state.get("supervisor_attempt", 0)), + } + async with self.telemetry.span( + "workflow.output_supervisor", + session_id=state.get("conversation_key") or state.get("session_id"), + input=candidate, + ): + decision = await self.output_supervisor_engine.evaluate(candidate, context) + action = decision.action.value + await self.telemetry.event( + "output_supervisor.completed", + { + "session_id": context["session_id"], + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "action": action, + "approved": decision.approved, + "guidance": decision.guidance, + }, + ) + + await self.observer.emit_ic( + "IC.OUTPUT_SUPERVISOR_COMPLETED", + { + "session_id": context["session_id"], + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "route": state.get("route"), + "intent": state.get("intent"), + "action": action, + "approved": decision.approved, + "result_count": len(decision.results), + }, + component="workflow.output_supervisor", + ) + + if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}: + final_answer = decision.candidate + elif decision.action == RailAction.HANDOVER: + final_answer = "Vou encaminhar seu atendimento para continuidade com um especialista." + else: + final_answer = decision.fallback_message + + return { + "answer": final_answer, + "final_answer": final_answer, + "supervisor_action": action, + "supervisor_guidance": decision.guidance, + "supervisor_attempt": int(state.get("supervisor_attempt", 0)) + (1 if decision.action == RailAction.RETRY else 0), + "supervisor_handover_reason": decision.handover_reason, + "output_supervisor_results": [ + { + "code": r.code, + "action": r.action.value, + "reason": r.reason, + "guidance": r.guidance, + "metadata": r.metadata, + } + for r in decision.results + ], + "output_guardrails_already_applied": True, + "guardrail_decisions": state.get("guardrail_decisions", []) + + [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])], + } + + async def output_guardrails(self, state): + if state.get("output_guardrails_already_applied"): + return {"final_answer": state.get("final_answer") or state.get("answer") or ""} + + async with self.telemetry.span( + "workflow.output_guardrails", + session_id=state.get("conversation_key") or state.get("session_id"), + input=state.get("answer"), + ): + await self.observer.emit_grl( + "001", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "phase": "output", + "route": state.get("route"), + "intent": state.get("intent"), + }, + component="workflow.output_guardrails.start", + ) + final, decisions = await self.guardrails.run_output( + state["answer"], state.get("context", {}) + ) + for _decision in decisions: + await self.guardrail_telemetry.evaluated("output", _decision) + await self.observer.emit_grl( + "002" if _decision.allowed else "004", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "phase": "output", + "rail_code": getattr(_decision, "code", None), + "allowed": bool(_decision.allowed), + "reason": getattr(_decision, "reason", None), + }, + component="workflow.output_guardrails.decision", + ) + if not _decision.allowed: + await self.guardrail_telemetry.blocked("output", _decision) + await self.telemetry.event( + "guardrails.output.completed", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "decisions": [d.model_dump() for d in decisions], + }, + ) + await self.observer.emit_grl( + "009", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "phase": "output", + "blocked": any(not d.allowed for d in decisions), + "decision_count": len(decisions), + }, + component="workflow.output_guardrails.final", + ) + return { + "final_answer": final, + "guardrail_decisions": state.get("guardrail_decisions", []) + + [d.model_dump() for d in decisions], + } + + async def judge(self, state): + async with self.telemetry.span( + "workflow.judge", + session_id=state.get("conversation_key") or state.get("session_id"), + input={"question": state.get("user_text"), "answer": state.get("final_answer")}, + ): + results = await self.judges.evaluate_all( + state["user_text"], state["final_answer"], state.get("context", {}) + ) + for _result in results: + await self.judge_telemetry.evaluated(_result) + await self.telemetry.event( + "judges.completed", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "results": [r.model_dump() for r in results], + }, + ) + return {"judge_results": [r.model_dump() for r in results]} + + async def supervisor_review(self, state): + async with self.telemetry.span( + "workflow.supervisor_review", + session_id=state.get("conversation_key") or state.get("session_id"), + input=state.get("final_answer"), + ): + ok, answer = await self.supervisor.review( + state["final_answer"], state.get("context", {}) + ) + await self.telemetry.event( + "supervisor.review.completed", + {"session_id": state.get("session_id"), "approved": ok}, + ) + return {"final_answer": answer if ok else answer} + + async def persist(self, state): + async with self.telemetry.span( + "workflow.persist", + session_id=state.get("conversation_key") or state.get("session_id"), + input={"route": state.get("route"), "intent": state.get("intent")}, + ): + await self.observer.emit_ic( + "AGENT_COMPLETED", + { + "session_id": state.get("conversation_key") or state["session_id"], + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "route": state.get("route"), + "intent": state.get("intent"), + "route_decision": state.get("route_decision"), + "judges": state.get("judge_results", []), + "mcp_tools": state.get("mcp_tools", []), + "mcp_results": state.get("mcp_results", []), + }, + ) + + await self.observer.emit_noc( + "006", + { + "session_id": state.get("conversation_key") or state["session_id"], + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "route": state.get("route"), + "intent": state.get("intent"), + "answer_chars": len(state.get("final_answer") or ""), + }, + component="workflow.persist", + ) + + await self.telemetry.event( + "agent.completed", + { + "session_id": state.get("conversation_key") or state["session_id"], + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "route": state.get("route"), + "intent": state.get("intent"), + "answer_chars": len(state.get("final_answer") or ""), + }, + ) + return state + + async def ainvoke(self, state): + thread_id = state.get("conversation_key") or state["session_id"] + config = {"configurable": {"thread_id": thread_id}} + async with self.telemetry.span( + "workflow.langgraph.ainvoke", + session_id=state.get("conversation_key") or state.get("session_id"), + user_id=state.get("context", {}).get("user_id"), + input={"user_text": state.get("user_text")}, + tags=["langgraph", "agent-workflow", f"routing-mode:{getattr(self.settings, 'ROUTING_MODE', 'router')}",], + ): + await self.workflow_telemetry.started("agent_workflow", state) + await self.observer.emit_noc( + "001", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "channel_id": (state.get("context") or {}).get("channel"), + "message_id": (state.get("context") or {}).get("message_id"), + "ura_call_id": (state.get("context") or {}).get("ura_call_id"), + }, + component="workflow.ainvoke", + ) + await self.observer.emit_ic( + "AGENT_STARTED", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "channel_id": (state.get("context") or {}).get("channel"), + "message_id": (state.get("context") or {}).get("message_id"), + "user_text_chars": len(state.get("user_text") or ""), + }, + component="workflow.ainvoke", + ) + try: + result = await self.graph.ainvoke(state, config=config) + await self.workflow_telemetry.completed("agent_workflow", result) + return result + except Exception as exc: + await self.workflow_telemetry.failed("agent_workflow", exc) + await self.observer.emit_noc( + "005", + { + "session_id": state.get("conversation_key") or state.get("session_id"), + "tenant_id": state.get("tenant_id"), + "agent_id": state.get("agent_id"), + "error": str(exc), + "exception_type": exc.__class__.__name__, + }, + component="workflow.ainvoke", + ) + raise diff --git a/app/workflows/backoffice_native_runtime.py b/app/workflows/backoffice_native_runtime.py new file mode 100644 index 0000000..912b57d --- /dev/null +++ b/app/workflows/backoffice_native_runtime.py @@ -0,0 +1,748 @@ +"""Backoffice TIM/ANATEL workflows executed by the framework runtime. + +This module is the migration boundary that makes the backoffice **framework-native**: + +* domain logic remains in business nodes/services/prompts copied from develop; +* the backend no longer imports or executes ``src.agent.graphs.*``; +* the framework runtime builds/compiles the LangGraph workflows, owns telemetry, + guardrails, judges, supervisor, checkpoint and persistence hooks; +* legacy REST contracts call ``execute_workflow`` instead of legacy executors. +""" + +from __future__ import annotations + +from typing import Any, Callable +from pathlib import Path +import inspect +import json +import logging + +import yaml + +from langgraph.graph import END, START, StateGraph + +from agent_framework.checkpoints.langgraph_saver import create_langgraph_checkpointer +from agent_framework.guardrails.pipeline import GuardrailPipeline +from agent_framework.guardrails.output_supervisor import OutputSupervisor +from agent_framework.guardrails.rail_action import RailAction +from agent_framework.guardrails.rail_result import RailResult +from agent_framework.judges.judge import JudgePipeline +from agent_framework.supervisor.supervisor import Supervisor +from agent_framework.observability.workflow_events import WorkflowTelemetry +from agent_framework.observability.guardrail_events import GuardrailTelemetry +from agent_framework.observability.judge_events import JudgeTelemetry +from agent_framework.observability.langgraph_telemetry import LangGraphDeepTelemetry +from agent_framework.observability.observer import AgentObserver + +from src.agent.state.agent_state import AgentState, create_initial_state, increment_iteration +from src.agent.state.steps import GraphStep +from src.agent.state.steps_emulator import EmulatorGraphStep +import src.agent.nodes as checklist_nodes +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, +) + +logger = logging.getLogger("backoffice.native_runtime") + + +def _project_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _load_yaml(path: str | Path) -> dict[str, Any]: + resolved = Path(path) + if not resolved.is_absolute(): + resolved = _project_root() / resolved + if not resolved.exists(): + raise FileNotFoundError(f"Configuração não encontrada: {resolved}") + with resolved.open("r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + if not isinstance(data, dict): + raise ValueError(f"Configuração YAML inválida: {resolved}") + data["_config_path"] = str(resolved) + return data + + +def _resolve_agent_profile(agent_id: str = "backoffice_anatel") -> dict[str, Any]: + agents_cfg = _load_yaml("config/agents.yaml") + for agent in agents_cfg.get("agents", []) or []: + if agent.get("agent_id") == agent_id: + profile = dict(agent) + profile["_agents_config_path"] = agents_cfg.get("_config_path") + return profile + raise ValueError(f"agent_id={agent_id!r} não encontrado em config/agents.yaml") + + +def _build_guardrail_pipeline(*, settings, observer: AgentObserver, guardrails_config: dict[str, Any]) -> GuardrailPipeline: + """Cria o GuardrailPipeline do framework amarrado ao profile do agente. + + O framework local pode ter assinaturas diferentes conforme a versão. Este + helper tenta usar config_path/config/profile quando suportado; em versões + antigas, instancia o pipeline e anexa a configuração ativa para telemetry e + debug, evitando depender apenas do config global. + """ + base_kwargs: dict[str, Any] = { + "observer": observer, + "enable_parallel": bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)), + "fail_fast": bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)), + } + config_path = guardrails_config.get("_config_path") + + try: + accepted = set(inspect.signature(GuardrailPipeline.__init__).parameters) + except Exception: + accepted = set() + + kwargs = dict(base_kwargs) + if "config_path" in accepted: + kwargs["config_path"] = config_path + if "config_file" in accepted: + kwargs["config_file"] = config_path + if "config" in accepted: + kwargs["config"] = guardrails_config + if "profile" in accepted: + kwargs["profile"] = guardrails_config.get("profile") or guardrails_config.get("agent_id") + if "agent_id" in accepted: + kwargs["agent_id"] = guardrails_config.get("agent_id") + + try: + pipeline = GuardrailPipeline(**kwargs) + except TypeError: + pipeline = GuardrailPipeline(**base_kwargs) + + for method_name in ("load_config", "configure", "set_config", "with_config"): + method = getattr(pipeline, method_name, None) + if callable(method): + try: + method(guardrails_config) + break + except TypeError: + try: + method(config_path) + break + except TypeError: + continue + + setattr(pipeline, "active_agent_id", guardrails_config.get("agent_id")) + setattr(pipeline, "active_profile", guardrails_config.get("profile")) + setattr(pipeline, "active_config_path", config_path) + setattr(pipeline, "active_config", guardrails_config) + return pipeline + + +class NativeOutputGuardrailRail: + code = "NATIVE_OUTPUT_GUARDRAILS" + + def __init__(self, pipeline: GuardrailPipeline): + self.pipeline = pipeline + + async def evaluate(self, candidate: str, context: dict[str, Any]): + final, decisions = await self.pipeline.run_output(candidate, context) + serialized = [d.model_dump() for d in decisions] + blocked = [d for d in decisions if not getattr(d, "allowed", True)] + if blocked: + first = blocked[0] + code = (getattr(first, "code", "") or "").upper() + action = RailAction.RETRY if code in {"REVPREC", "CMP", "SCO", "GND"} else RailAction.BLOCK + return RailResult( + code=code or self.code, + action=action, + reason=getattr(first, "reason", "Resposta bloqueada por guardrail de saída"), + guidance=getattr(first, "reason", "Regerar resposta seguindo as políticas de saída."), + sanitized_text=final, + metadata={"native_decisions": serialized}, + ) + if final != candidate: + return RailResult( + code=self.code, + action=RailAction.SANITIZE, + reason="Resposta sanitizada por guardrail de saída.", + sanitized_text=final, + metadata={"native_decisions": serialized}, + ) + return RailResult( + code=self.code, + action=RailAction.ALLOW, + reason="Resposta aprovada pelos guardrails de saída.", + sanitized_text=final, + metadata={"native_decisions": serialized}, + ) + + +class BackofficeNativeRuntime: + """Framework-owned workflow runtime for the backoffice domain.""" + + def __init__(self, *, settings, telemetry, analytics, observer: AgentObserver | None = None): + self.settings = settings + self.telemetry = telemetry + self.analytics = analytics + self.observer = observer or AgentObserver(analytics=analytics) + self.agent_profile = _resolve_agent_profile("backoffice_anatel") + self.guardrails_config = _load_yaml(self.agent_profile["guardrails_config_path"]) + self.guardrails = _build_guardrail_pipeline( + settings=settings, + observer=self.observer, + guardrails_config=self.guardrails_config, + ) + logger.info( + "Backoffice guardrails bound to framework profile agent_id=%s profile=%s config_path=%s input=%s output=%s", + self.guardrails_config.get("agent_id"), + self.guardrails_config.get("profile"), + self.guardrails_config.get("_config_path"), + [r.get("code") for r in self.guardrails_config.get("input", [])], + [r.get("code") for r in self.guardrails_config.get("output", [])], + ) + self.output_supervisor_engine = OutputSupervisor( + rails=[NativeOutputGuardrailRail(self.guardrails)], + observer=self.observer, + max_retries=int(getattr(settings, "OUTPUT_SUPERVISOR_MAX_RETRIES", 3)), + enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)), + fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)), + ) + self.judges = JudgePipeline() + self.supervisor = Supervisor() + self.workflow_telemetry = WorkflowTelemetry(telemetry) + self.guardrail_telemetry = GuardrailTelemetry(telemetry) + self.judge_telemetry = JudgeTelemetry(telemetry) + self.langgraph_telemetry = LangGraphDeepTelemetry(telemetry) + self._graphs: dict[str, Any] = {} + + def _base_event_payload(self, state: AgentState | None = None, **extra: Any) -> dict[str, Any]: + state = state or {} + metadata = state.get("metadata", {}) or {} + payload = { + "workflow_id": metadata.get("framework_workflow_id"), + "session_id": state.get("session_id") or metadata.get("session_id") or metadata.get("transaction_id"), + "transaction_id": metadata.get("transaction_id"), + "agent_id": metadata.get("agent_id") or self.guardrails_config.get("agent_id"), + "guardrails_profile": metadata.get("guardrails_profile") or self.guardrails_config.get("profile"), + "framework_native": True, + } + payload.update({k: v for k, v in extra.items() if v is not None}) + return payload + + async def _safe_emit_ic(self, code: str, state: AgentState | None = None, payload: dict[str, Any] | None = None, *, component: str) -> None: + try: + await self.observer.emit_ic(code, self._base_event_payload(state, **(payload or {})), component=component) + except Exception as exc: + logger.debug("Falha ao emitir IC %s: %s", code, exc) + + async def _safe_emit_noc(self, code: str, state: AgentState | None = None, payload: dict[str, Any] | None = None, *, component: str) -> None: + try: + normalized = code if str(code).startswith("NOC.") else f"NOC.{str(code).zfill(3)}" + await self.observer.emit_noc(normalized, self._base_event_payload(state, **(payload or {})), component=component) + except Exception as exc: + logger.debug("Falha ao emitir NOC %s: %s", code, exc) + + async def _safe_emit_grl(self, code: str, state: AgentState | None = None, payload: dict[str, Any] | None = None, *, component: str) -> None: + try: + normalized = code if str(code).startswith("GRL.") else f"GRL.{str(code).zfill(3)}" + await self.observer.emit_grl(normalized, self._base_event_payload(state, **(payload or {})), component=component) + except Exception as exc: + logger.debug("Falha ao emitir GRL %s: %s", code, exc) + + async def _emit_by_code(self, code: str, state: AgentState, payload: dict[str, Any] | None, *, component: str) -> None: + code = str(code or "IC.UNKNOWN") + if code.startswith("NOC."): + await self._safe_emit_noc(code, state, payload, component=component) + elif code.startswith("GRL."): + await self._safe_emit_grl(code, state, payload, component=component) + else: + await self._safe_emit_ic(code, state, payload, component=component) + + async def _bridge_legacy_ics(self, state: AgentState, node_name: str) -> None: + """Reemite IC/NOC/GRL legados do develop pelo AgentObserver do framework. + + Os nós originais ainda chamam ``agent_framework.observer.event(...)``. + O coletor legado captura esses eventos; esta ponte pega apenas os novos + eventos desde a última etapa e os publica de forma padronizada no + observer do framework, preservando AGA.*, NOC.* e GRL.* no Langfuse/OCI. + """ + try: + from src.utils.ics_collector import ICsCollector + except Exception: + return + metadata = state.setdefault("metadata", {}) + session_id = state.get("session_id") or metadata.get("transaction_id") + try: + events = ICsCollector.get_current(session_id) if session_id else [] + except Exception: + events = [] + last_idx = int(metadata.get("_framework_ics_bridge_index", 0) or 0) + new_events = events[last_idx:] + if not new_events: + return + metadata["_framework_ics_bridge_index"] = len(events) + bridge_log = metadata.setdefault("framework_ics_bridge", []) + for item in new_events: + code = str(item.get("code") or "IC.UNKNOWN") + event_payload = dict(item.get("metadata") or {}) + event_payload.update({ + "legacy_bridge": True, + "legacy_type": item.get("type"), + "legacy_description": item.get("description"), + "legacy_timestamp": item.get("timestamp"), + "source_node": node_name, + }) + await self._emit_by_code(code, state, event_payload, component=f"backoffice.native_runtime.legacy_bridge.{node_name}") + bridge_log.append({"code": code, "type": item.get("type"), "node": node_name}) + + def _node(self, name: str, fn: Callable[[AgentState], Any]): + async def _wrapped(state: AgentState) -> AgentState: + async with self.langgraph_telemetry.node(name, state): + await self.telemetry.event( + "backoffice.workflow.node.started", + {"workflow_id": state.get("metadata", {}).get("framework_workflow_id"), "node": name, "session_id": state.get("session_id")}, + kind="workflow", + ) + await self._safe_emit_ic( + "IC.BACKOFFICE_NODE_STARTED", + state, + {"node": name}, + component=f"backoffice.native_runtime.node.{name}", + ) + try: + result = await fn(state) + except Exception as exc: + await self._safe_emit_noc( + "NOC.009", + state, + {"node": name, "type": "ERROR", "error_type": type(exc).__name__, "error": str(exc)}, + component=f"backoffice.native_runtime.node.{name}", + ) + raise + await self._bridge_legacy_ics(result, name) + await self.telemetry.event( + "backoffice.workflow.node.completed", + {"workflow_id": result.get("metadata", {}).get("framework_workflow_id"), "node": name, "session_id": result.get("session_id")}, + kind="workflow", + ) + await self._safe_emit_ic( + "IC.BACKOFFICE_NODE_COMPLETED", + result, + {"node": name, "has_error": bool(result.get("error"))}, + component=f"backoffice.native_runtime.node.{name}", + ) + return result + return _wrapped + + async def _framework_input_guardrails(self, state: AgentState) -> AgentState: + await self._safe_emit_grl( + "GRL.001", + state, + {"phase": "input", "status": "started", "rails": [r.get("code") for r in self.guardrails_config.get("input", []) if r.get("enabled", True)]}, + component="backoffice.native_runtime.guardrails.input", + ) + payload = state.get("metadata", {}).get("request_context", {}) + serialized = json.dumps(payload, ensure_ascii=False, default=str) + context = { + **state.get("metadata", {}), + "workflow_id": state.get("metadata", {}).get("framework_workflow_id"), + "agent_id": self.guardrails_config.get("agent_id"), + "guardrails_profile": self.guardrails_config.get("profile"), + "guardrails_config_path": self.guardrails_config.get("_config_path"), + "active_input_rails": [r.get("code") for r in self.guardrails_config.get("input", []) if r.get("enabled", True)], + "active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)], + } + sanitized, decisions = await self.guardrails.run_input(serialized, context) + state.setdefault("metadata", {})["framework_input_guardrails"] = [d.model_dump() for d in decisions] + blocked = [d for d in decisions if not getattr(d, "allowed", True)] + await self._safe_emit_grl( + "GRL.002", + state, + { + "phase": "input", + "status": "completed", + "decision_count": len(decisions), + "blocked": bool(blocked), + "codes": [getattr(d, "code", None) for d in decisions], + }, + component="backoffice.native_runtime.guardrails.input", + ) + if blocked: + first = blocked[0] + state["error"] = {"type": "InputGuardrailBlocked", "message": getattr(first, "reason", "Entrada bloqueada"), "step": "framework_input_guardrails"} + state["final_response"] = sanitized or "Entrada bloqueada por política de segurança." + await self._safe_emit_grl( + "GRL.003", + state, + {"phase": "input", "status": "blocked", "code": getattr(first, "code", None), "reason": getattr(first, "reason", None)}, + component="backoffice.native_runtime.guardrails.input", + ) + return state + + @staticmethod + def _after_input_guardrails(state: AgentState) -> str: + return "blocked" if state.get("error", {}).get("type") == "InputGuardrailBlocked" else "continue" + + async def _framework_output_supervisor(self, state: AgentState) -> AgentState: + await self._safe_emit_grl( + "GRL.004", + state, + {"phase": "output_supervisor", "status": "started"}, + component="backoffice.native_runtime.output_supervisor", + ) + candidate = state.get("final_response") or state.get("response") or str(state.get("metadata", {}).get("request_context", {}).get("transactionId", "")) + context = { + **state.get("metadata", {}), + "session_id": state.get("session_id"), + "agent_id": self.guardrails_config.get("agent_id"), + "guardrails_profile": self.guardrails_config.get("profile"), + "guardrails_config_path": self.guardrails_config.get("_config_path"), + "active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)], + } + decision = await self.output_supervisor_engine.evaluate(candidate, context) + if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}: + final = decision.candidate + elif decision.action == RailAction.HANDOVER: + final = "Encaminhado para continuidade com especialista." + else: + final = decision.fallback_message + state["final_response"] = final + state.setdefault("metadata", {})["framework_output_supervisor"] = { + "action": decision.action.value, + "approved": decision.approved, + "results": [ + {"code": r.code, "action": r.action.value, "reason": r.reason, "guidance": r.guidance, "metadata": r.metadata} + for r in decision.results + ], + } + await self._safe_emit_grl( + "GRL.005", + state, + { + "phase": "output_supervisor", + "status": "completed", + "action": decision.action.value, + "approved": decision.approved, + "result_codes": [r.code for r in decision.results], + }, + component="backoffice.native_runtime.output_supervisor", + ) + if decision.action not in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}: + await self._safe_emit_grl( + "GRL.006", + state, + {"phase": "output_supervisor", "status": "blocked_or_handover", "action": decision.action.value}, + component="backoffice.native_runtime.output_supervisor", + ) + return state + + async def _framework_output_guardrails(self, state: AgentState) -> AgentState: + await self._safe_emit_grl( + "GRL.007", + state, + {"phase": "output", "status": "started", "rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)]}, + component="backoffice.native_runtime.guardrails.output", + ) + candidate = state.get("final_response") or "" + context = { + **state.get("metadata", {}), + "agent_id": self.guardrails_config.get("agent_id"), + "guardrails_profile": self.guardrails_config.get("profile"), + "guardrails_config_path": self.guardrails_config.get("_config_path"), + "active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)], + } + final, decisions = await self.guardrails.run_output(candidate, context) + state["final_response"] = final + state.setdefault("metadata", {})["framework_output_guardrails"] = [d.model_dump() for d in decisions] + blocked = [d for d in decisions if not getattr(d, "allowed", True)] + await self._safe_emit_grl( + "GRL.008", + state, + { + "phase": "output", + "status": "completed", + "decision_count": len(decisions), + "blocked": bool(blocked), + "sanitized": final != candidate, + "codes": [getattr(d, "code", None) for d in decisions], + }, + component="backoffice.native_runtime.guardrails.output", + ) + if blocked or final != candidate: + await self._safe_emit_grl( + "GRL.009", + state, + {"phase": "output", "status": "blocked_or_sanitized", "blocked": bool(blocked), "sanitized": final != candidate}, + component="backoffice.native_runtime.guardrails.output", + ) + return state + + async def _framework_judges(self, state: AgentState) -> AgentState: + payload = state.get("metadata", {}).get("request_context", {}) + question = json.dumps(payload, ensure_ascii=False, default=str) + answer = state.get("final_response") or "" + results = await self.judges.evaluate_all(question, answer, state.get("metadata", {})) + state.setdefault("metadata", {})["framework_judges"] = [r.model_dump() for r in results] + return state + + async def _framework_supervisor_review(self, state: AgentState) -> AgentState: + answer = state.get("final_response") or "" + ok, reviewed = await self.supervisor.review(answer, state.get("metadata", {})) + state["final_response"] = reviewed if ok else reviewed + state.setdefault("metadata", {})["framework_supervisor_review"] = {"approved": ok} + return state + + async def _framework_persist(self, state: AgentState) -> AgentState: + await self._safe_emit_ic( + "IC.BACKOFFICE_WORKFLOW_COMPLETED", + state, + { + "current_step": str(state.get("current_step")), + "has_error": bool(state.get("error")), + }, + component="backoffice.native_runtime.persist", + ) + await self._safe_emit_noc( + "NOC.006", + state, + { + "type": "INFO" if not state.get("error") else "FAILURE", + "status": "Backoffice workflow completed", + "current_step": str(state.get("current_step")), + }, + component="backoffice.native_runtime.persist", + ) + return state + + def _compile(self, workflow_id: str): + if workflow_id == "backoffice_checklist": + return self._build_checklist_graph() + if workflow_id == "backoffice_response_emulator": + return self._build_emulator_graph() + raise ValueError(f"Unknown workflow_id={workflow_id}") + + def get_graph(self, workflow_id: str): + graph = self._graphs.get(workflow_id) + if graph is None: + graph = self._compile(workflow_id) + self._graphs[workflow_id] = graph + return graph + + async def execute_workflow(self, workflow_id: str, *, payload: dict[str, Any], transaction_id: str | None = None, app_state=None, metadata: dict[str, Any] | None = None) -> AgentState: + transaction_id = transaction_id or payload.get("transactionId") or payload.get("transaction_id") or "backoffice-session" + state = create_initial_state(session_id=transaction_id) + state["metadata"].update(metadata or {}) + state["metadata"].update({ + "transaction_id": transaction_id, + "request_context": payload, + "framework_workflow_id": workflow_id, + "framework_native": True, + "agent_id": self.guardrails_config.get("agent_id"), + "guardrails_profile": self.guardrails_config.get("profile"), + "guardrails_config_path": self.guardrails_config.get("_config_path"), + "active_input_rails": [r.get("code") for r in self.guardrails_config.get("input", []) if r.get("enabled", True)], + "active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)], + "_oci_producer": getattr(app_state, "oci_producer", None) if app_state is not None else None, + "_framework_ics_bridge_index": 0, + }) + try: + from src.utils.ics_collector import ICsCollector + ICsCollector.start(transaction_id) + except Exception: + pass + await self._safe_emit_noc( + "NOC.001", + state, + {"type": "INFO", "status": "Backoffice workflow started"}, + component="backoffice.native_runtime.execute", + ) + await self._safe_emit_ic( + "IC.BACKOFFICE_WORKFLOW_STARTED", + state, + {"payload_keys": sorted(list(payload.keys()))}, + component="backoffice.native_runtime.execute", + ) + graph = self.get_graph(workflow_id) + config = {"configurable": {"thread_id": f"{workflow_id}:{transaction_id}"}} + try: + final_state = await graph.ainvoke(state, config=config) + await self._bridge_legacy_ics(final_state, "workflow_end") + return final_state + except Exception as exc: + await self._safe_emit_noc( + "NOC.009", + state, + {"type": "ERROR", "status": "Backoffice workflow failed", "error_type": type(exc).__name__, "error": str(exc)}, + component="backoffice.native_runtime.execute", + ) + raise + finally: + try: + final_ref = locals().get("final_state", state) + final_ref.get("metadata", {}).pop("_oci_producer", None) + final_ref.get("metadata", {}).pop("_framework_ics_bridge_index", None) + except Exception: + pass + try: + from src.utils.ics_collector import ICsCollector + ICsCollector.stop(transaction_id) + except Exception: + pass + + # -------------------- Checklist workflow -------------------- + def _build_checklist_graph(self): + builder = StateGraph(AgentState) + builder.add_node("framework_input_guardrails", self._node("framework_input_guardrails", self._framework_input_guardrails)) + builder.add_node("fetch_ticket", self._node("fetch_ticket", self._fetch_ticket)) + builder.add_node(GraphStep.VALIDATION, self._node(str(GraphStep.VALIDATION), checklist_nodes.validation_node.validate_ticket)) + builder.add_node(GraphStep.BYPASS_RULES, self._node(str(GraphStep.BYPASS_RULES), checklist_nodes.bypass_rules_node.evaluate_bypass_rules)) + builder.add_node(GraphStep.CACHE_CHECK, self._node(str(GraphStep.CACHE_CHECK), checklist_nodes.cache_check_node.check_cache_node)) + builder.add_node(GraphStep.IMDB_ENRICHMENT, self._node(str(GraphStep.IMDB_ENRICHMENT), checklist_nodes.imdb_enrichment_node.imdb_enrich_ticket)) + builder.add_node(GraphStep.IDENTITY_VERIFICATION, self._node(str(GraphStep.IDENTITY_VERIFICATION), checklist_nodes.identity_verification_node.perform_identity_verification)) + builder.add_node(GraphStep.SPEECH_ENRICHMENT, self._node(str(GraphStep.SPEECH_ENRICHMENT), checklist_nodes.speech_enrichment_node.enrich_with_speech)) + builder.add_node("knowledge_base_enrichment", self._node("knowledge_base_enrichment", checklist_nodes.knowledge_base_enrichment_node.enrich_with_knowledge_base)) + builder.add_node(GraphStep.CANCELING_ANALYSIS, self._node(str(GraphStep.CANCELING_ANALYSIS), checklist_nodes.canceling_analysis_node.perform_canceling_analysis)) + builder.add_node(GraphStep.TIM_COMPLAINT_ANALYSIS, self._node(str(GraphStep.TIM_COMPLAINT_ANALYSIS), checklist_nodes.tim_complaint_analysis_node.perform_tim_complaint_analysis)) + builder.add_node("different_complaint_operator", self._node("different_complaint_operator", checklist_nodes.different_complaint_operator_node.perform_different_operator)) + builder.add_node("undefined_complaint_operator", self._node("undefined_complaint_operator", checklist_nodes.undefined_complaint_operator_node.perform_undefined_complaint)) + builder.add_node("tim_complaint", self._node("tim_complaint", checklist_nodes.tim_complaint_node.handle_tim_complaint)) + builder.add_node(GraphStep.RECLASSIFICATION_ANALYSIS, self._node(str(GraphStep.RECLASSIFICATION_ANALYSIS), checklist_nodes.reclassification_analysis_node.perform_reclassification_analysis)) + builder.add_node(GraphStep.TREATMENT_DECISION, self._node(str(GraphStep.TREATMENT_DECISION), checklist_nodes.treatment_decision_node.treatment_decision)) + builder.add_node(GraphStep.SIEBEL_SR_OPENING, self._node(str(GraphStep.SIEBEL_SR_OPENING), checklist_nodes.siebel_sr_opening_node.open_siebel_sr)) + builder.add_node("framework_output_supervisor", self._node("framework_output_supervisor", self._framework_output_supervisor)) + builder.add_node("framework_output_guardrails", self._node("framework_output_guardrails", self._framework_output_guardrails)) + builder.add_node("framework_judges", self._node("framework_judges", self._framework_judges)) + builder.add_node("framework_supervisor_review", self._node("framework_supervisor_review", self._framework_supervisor_review)) + builder.add_node("framework_persist", self._node("framework_persist", self._framework_persist)) + + builder.add_edge(START, "framework_input_guardrails") + builder.add_conditional_edges("framework_input_guardrails", self._after_input_guardrails, {"blocked": "framework_persist", "continue": "fetch_ticket"}) + builder.add_edge("fetch_ticket", GraphStep.VALIDATION) + builder.add_conditional_edges(GraphStep.VALIDATION, checklist_nodes.validation_node.should_continue, {"continue": GraphStep.BYPASS_RULES, "reject": "framework_output_supervisor"}) + builder.add_edge(GraphStep.BYPASS_RULES, GraphStep.CACHE_CHECK) + builder.add_conditional_edges(GraphStep.CACHE_CHECK, self._route_after_cache_check, {GraphStep.TREATMENT_DECISION: GraphStep.TREATMENT_DECISION, GraphStep.CANCELING_ANALYSIS: GraphStep.CANCELING_ANALYSIS, GraphStep.IMDB_ENRICHMENT: GraphStep.IMDB_ENRICHMENT}) + builder.add_conditional_edges(GraphStep.IMDB_ENRICHMENT, checklist_nodes.imdb_enrichment_node.should_continue, {"continue": GraphStep.IDENTITY_VERIFICATION, "failed": "framework_output_supervisor"}) + builder.add_conditional_edges(GraphStep.IDENTITY_VERIFICATION, checklist_nodes.identity_verification_node.route_after_identity_verification, {"proceed": GraphStep.SPEECH_ENRICHMENT, "cancel": GraphStep.SIEBEL_SR_OPENING, "smart_human": GraphStep.TREATMENT_DECISION, "failed": "framework_output_supervisor"}) + builder.add_edge(GraphStep.SPEECH_ENRICHMENT, "knowledge_base_enrichment") + builder.add_conditional_edges("knowledge_base_enrichment", self._route_after_knowledge_base, {GraphStep.CANCELING_ANALYSIS: GraphStep.CANCELING_ANALYSIS, GraphStep.TREATMENT_DECISION: GraphStep.TREATMENT_DECISION}) + builder.add_conditional_edges(GraphStep.CANCELING_ANALYSIS, self._route_after_canceling, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.TIM_COMPLAINT_ANALYSIS, "finalize": "framework_output_supervisor"}) + builder.add_conditional_edges(GraphStep.TIM_COMPLAINT_ANALYSIS, self._route_after_tim_complaint_analysis, {"tim_complaint": "tim_complaint", "different_complaint_operator": "different_complaint_operator", "undefined_complaint_operator": "undefined_complaint_operator", "finalize": "framework_output_supervisor"}) + builder.add_edge("tim_complaint", GraphStep.RECLASSIFICATION_ANALYSIS) + builder.add_conditional_edges("different_complaint_operator", self._route_after_operator_check, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.RECLASSIFICATION_ANALYSIS}) + builder.add_conditional_edges("undefined_complaint_operator", self._route_after_operator_check, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.RECLASSIFICATION_ANALYSIS}) + builder.add_conditional_edges(GraphStep.RECLASSIFICATION_ANALYSIS, self._route_after_reclassification, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.TREATMENT_DECISION, "finalize": "framework_output_supervisor"}) + builder.add_edge(GraphStep.TREATMENT_DECISION, GraphStep.SIEBEL_SR_OPENING) + builder.add_edge(GraphStep.SIEBEL_SR_OPENING, "framework_output_supervisor") + builder.add_edge("framework_output_supervisor", "framework_output_guardrails") + builder.add_edge("framework_output_guardrails", "framework_judges") + builder.add_edge("framework_judges", "framework_supervisor_review") + builder.add_edge("framework_supervisor_review", "framework_persist") + builder.add_edge("framework_persist", END) + return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings)) + + async def _fetch_ticket(self, state: AgentState) -> AgentState: + increment_iteration(state) + return await checklist_nodes.fetch_ticket_node.fetch_ticket_data(state) + + @staticmethod + def _route_after_cache_check(state: AgentState) -> str: + 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 + + @staticmethod + def _route_after_knowledge_base(state: AgentState) -> str: + return GraphStep.TREATMENT_DECISION if state.get("bypass_treatment_validations") else GraphStep.CANCELING_ANALYSIS + + @staticmethod + def _route_after_canceling(state: AgentState) -> str: + step = state.get("current_step") + if step == GraphStep.CANCELING_ANALYSIS_CANCEL_TICKET: + return GraphStep.SIEBEL_SR_OPENING + if step == GraphStep.PROCEED_GRAPH: + return GraphStep.PROCEED_GRAPH + return "finalize" + + @staticmethod + 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" + if decision == "inconclusivo": + return "undefined_complaint_operator" + return "finalize" + + @staticmethod + 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 + + @staticmethod + 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 + return "finalize" + + # -------------------- Emulator workflow -------------------- + def _build_emulator_graph(self): + builder = StateGraph(AgentState) + builder.add_node("framework_input_guardrails", self._node("framework_input_guardrails", self._framework_input_guardrails)) + builder.add_node(EmulatorGraphStep.RESPONSE_EMULATION_START, self._node(str(EmulatorGraphStep.RESPONSE_EMULATION_START), start_response_emulation_node.start_response_emulation)) + builder.add_node(EmulatorGraphStep.FETCH_CASE, self._node(str(EmulatorGraphStep.FETCH_CASE), fetch_case_node.fetch_case)) + builder.add_node(EmulatorGraphStep.VALIDATE_ACTIONS, self._node(str(EmulatorGraphStep.VALIDATE_ACTIONS), validate_actions_node.validate_actions)) + builder.add_node(EmulatorGraphStep.ROUTER_DECISION, self._node(str(EmulatorGraphStep.ROUTER_DECISION), router_node.route)) + builder.add_node(EmulatorGraphStep.RETRIEVE_TEMPLATES, self._node(str(EmulatorGraphStep.RETRIEVE_TEMPLATES), retrieve_templates_node.retrieve_templates)) + builder.add_node(EmulatorGraphStep.RETRIEVE_HISTORY, self._node(str(EmulatorGraphStep.RETRIEVE_HISTORY), retrieve_history_node.retrieve_history)) + builder.add_node(EmulatorGraphStep.GENERATE_RESPONSE, self._node(str(EmulatorGraphStep.GENERATE_RESPONSE), generate_response_node.generate_response)) + builder.add_node(EmulatorGraphStep.VALIDATE_RESPONSE, self._node(str(EmulatorGraphStep.VALIDATE_RESPONSE), validate_response_node.validate_response)) + builder.add_node(EmulatorGraphStep.PERSIST_DRAFT, self._node(str(EmulatorGraphStep.PERSIST_DRAFT), persist_draft_node.persist_draft)) + builder.add_node(EmulatorGraphStep.APPROVE_DRAFT, self._node(str(EmulatorGraphStep.APPROVE_DRAFT), approve_draft_node.approve_draft)) + builder.add_node(EmulatorGraphStep.CLOSE_CASE, self._node(str(EmulatorGraphStep.CLOSE_CASE), close_case_node.close_case)) + builder.add_node("framework_output_supervisor", self._node("framework_output_supervisor", self._framework_output_supervisor)) + builder.add_node("framework_output_guardrails", self._node("framework_output_guardrails", self._framework_output_guardrails)) + builder.add_node("framework_judges", self._node("framework_judges", self._framework_judges)) + builder.add_node("framework_supervisor_review", self._node("framework_supervisor_review", self._framework_supervisor_review)) + builder.add_node("framework_persist", self._node("framework_persist", self._framework_persist)) + + builder.add_edge(START, "framework_input_guardrails") + builder.add_conditional_edges("framework_input_guardrails", self._after_input_guardrails, {"blocked": "framework_persist", "continue": EmulatorGraphStep.RESPONSE_EMULATION_START}) + builder.add_edge(EmulatorGraphStep.RESPONSE_EMULATION_START, EmulatorGraphStep.FETCH_CASE) + builder.add_conditional_edges(EmulatorGraphStep.FETCH_CASE, self._emulator_route_after_fetch, {"generate": EmulatorGraphStep.VALIDATE_ACTIONS, "approve": EmulatorGraphStep.APPROVE_DRAFT, "close": EmulatorGraphStep.CLOSE_CASE, "failed": "framework_output_supervisor"}) + builder.add_conditional_edges(EmulatorGraphStep.VALIDATE_ACTIONS, validate_actions_node.should_continue, {"continue": EmulatorGraphStep.ROUTER_DECISION, "failed": "framework_output_supervisor"}) + builder.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}) + builder.add_conditional_edges(EmulatorGraphStep.RETRIEVE_TEMPLATES, router_node.next_step_after_templates, {EmulatorGraphStep.RETRIEVE_HISTORY: EmulatorGraphStep.RETRIEVE_HISTORY, EmulatorGraphStep.GENERATE_RESPONSE: EmulatorGraphStep.GENERATE_RESPONSE}) + builder.add_edge(EmulatorGraphStep.RETRIEVE_HISTORY, EmulatorGraphStep.GENERATE_RESPONSE) + builder.add_conditional_edges(EmulatorGraphStep.GENERATE_RESPONSE, generate_response_node.should_continue, {"continue": EmulatorGraphStep.VALIDATE_RESPONSE, "failed": "framework_output_supervisor"}) + builder.add_edge(EmulatorGraphStep.VALIDATE_RESPONSE, EmulatorGraphStep.PERSIST_DRAFT) + builder.add_edge(EmulatorGraphStep.PERSIST_DRAFT, "framework_output_supervisor") + builder.add_edge(EmulatorGraphStep.APPROVE_DRAFT, "framework_output_supervisor") + builder.add_edge(EmulatorGraphStep.CLOSE_CASE, "framework_output_supervisor") + builder.add_edge("framework_output_supervisor", "framework_output_guardrails") + builder.add_edge("framework_output_guardrails", "framework_judges") + builder.add_edge("framework_judges", "framework_supervisor_review") + builder.add_edge("framework_supervisor_review", "framework_persist") + builder.add_edge("framework_persist", END) + return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings)) + + @staticmethod + def _emulator_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" diff --git a/config/.DS_Store b/config/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..46d9067802dc086f4de81150b20c87f1f8d2d9af GIT binary patch literal 6148 zcmeH~Jx&8L5QS$TLXpy>q;yM}8<=Q0K`sCxC=>}BDNv>Hk+=mX;1ErQ$8U*VS-O!B)S(F+1W z;GYrDQL|{~_$WVHd!NU%HlbXj(CAm#fT7*F1fauxra)bR1GyaY`j7v{uKj<#$gWHQQ{Z1I;DUG-PeDo7)&@CVYeV=g qoQ?A;L0^JKmt%jzOYs@p8}5aC0*n+ZK}2BoBj9DQ#uWHd1wH|_b&g5^ literal 0 HcmV?d00001 diff --git a/config/agents/backoffice_anatel/guardrails.yaml b/config/agents/backoffice_anatel/guardrails.yaml new file mode 100644 index 0000000..904c81f --- /dev/null +++ b/config/agents/backoffice_anatel/guardrails.yaml @@ -0,0 +1,54 @@ +agent_id: backoffice_anatel +profile: backoffice_anatel_enterprise +input: + - code: INPUT_SIZE + enabled: true + action: block + max_chars: 12000 + - code: MSK + enabled: true + action: sanitize + - code: PINJ + enabled: true + action: block + - code: TOX + enabled: true + action: block + - code: DLEX_IN + enabled: true + action: block + - code: RAGSEC + enabled: true + action: block + - code: VLOOP + enabled: true + action: block +output: + - code: REVPREC + enabled: true + action: sanitize + - code: DLEX_OUT + enabled: true + action: sanitize + - code: OOS + enabled: true + action: review + - code: CMP + enabled: true + action: review + - code: AOFERTA + enabled: true + action: block +business_rules: + require_evidence_for: + - protocolo + - cliente + - contrato + - status_siebel + - acao_operacional + forbid_without_tool_ok: + - registrar_acao_backoffice + - registrar_acao_siebel + domain_workflows: + checklist: src.agent.graphs.main_graph.create_main_agent_graph + response_emulator: src.agent.graphs.emulator_graph.create_emulator_graph diff --git a/config/agents/backoffice_anatel/judges.yaml b/config/agents/backoffice_anatel/judges.yaml new file mode 100644 index 0000000..239c81f --- /dev/null +++ b/config/agents/backoffice_anatel/judges.yaml @@ -0,0 +1,20 @@ +agent_id: backoffice_anatel +judges: + - name: response_quality + enabled: true + threshold: 0.75 + - name: groundedness + enabled: true + threshold: 0.80 + - name: backoffice_no_fabricated_protocol + enabled: true + description: Verifica se a resposta não inventa protocolo, cliente, contrato, SLA ou status operacional. + - name: siebel_action_requires_tool_ok + enabled: true + description: Bloqueia confirmação de registro/fechamento Siebel sem evidência ok/registered. + - name: anatel_domain_traceability + enabled: true + description: Exige rastreabilidade para decisão de cancelamento, reclassificação, tratamento ou encaminhamento. + - name: response_emulator_policy + enabled: true + description: Valida resposta formal ANATEL gerada pelo emulador antes de persistir/aprovar/fechar. diff --git a/config/agents/backoffice_anatel/prompt_policy.yaml b/config/agents/backoffice_anatel/prompt_policy.yaml new file mode 100644 index 0000000..e7a1b1b --- /dev/null +++ b/config/agents/backoffice_anatel/prompt_policy.yaml @@ -0,0 +1,25 @@ +agent_id: backoffice_anatel +system_prompt: | + Você é o agente Backoffice/ANATEL da TIM executado pelo Agent Framework. + + Use a arquitetura padrão do framework para gateway, identidade, sessão, memória, + checkpoint, guardrails, judges, supervisor, observabilidade e MCP. + + Para eventos completos de reclamação ANATEL, preserve o workflow original develop: + fetch_ticket, validation, bypass_rules, cache_check, imdb_enrichment, + identity_verification, speech_enrichment, knowledge_base_enrichment, + canceling_analysis, tim_complaint_analysis, tratamento por operadora, + reclassification_analysis, treatment_decision e siebel_sr_opening. + + Para Response Emulator, preserve o workflow original develop: + start_response_emulation, fetch_case, validate_actions, router, + retrieve_templates, retrieve_history, generate_response, validate_response, + persist_draft, approve_draft e close_case. + + Regras obrigatórias: + - Não invente protocolo, cliente, contrato, status, SLA, parecer, classificação ou ação Siebel. + - Não confirme registro operacional sem retorno ok/registered da ferramenta ou workflow. + - Use evidências de IMDB, Siebel, Speech Analytics, TAIS KB, ABRT e Portabilidade quando disponíveis. + - Se uma evidência não for encontrada, declare a ausência de evidência de forma objetiva. + - Se faltar identificador de reclamação/protocolo/chamado, peça somente esse dado. + - Toda resposta passa por OutputSupervisor, guardrails de saída e judges do framework. diff --git a/config/agents/retail_orders/guardrails.yaml b/config/agents/retail_orders/guardrails.yaml new file mode 100644 index 0000000..9fe094a --- /dev/null +++ b/config/agents/retail_orders/guardrails.yaml @@ -0,0 +1,8 @@ +input: + - code: MSK + enabled: true + - code: VLOOP + enabled: true +output: + - code: REVPREC + enabled: true diff --git a/config/agents/retail_orders/judges.yaml b/config/agents/retail_orders/judges.yaml new file mode 100644 index 0000000..62fc7c7 --- /dev/null +++ b/config/agents/retail_orders/judges.yaml @@ -0,0 +1,7 @@ +judges: + - name: response_quality + enabled: true + threshold: 0.7 + - name: groundedness + enabled: true + threshold: 0.6 diff --git a/config/agents/retail_orders/prompt_policy.yaml b/config/agents/retail_orders/prompt_policy.yaml new file mode 100644 index 0000000..f872a2b --- /dev/null +++ b/config/agents/retail_orders/prompt_policy.yaml @@ -0,0 +1,6 @@ +id: retail_orders_prompt_policy +version: 1 +description: Prompt base isolado do agente de varejo/pedidos. +system_prefix: | + Você é um agente corporativo de varejo especializado em pedidos, entrega, troca, devolução e garantia. + Seja claro, objetivo e não use regras de negócio de telecom neste agente. diff --git a/config/agents/telecom_contas/guardrails.yaml b/config/agents/telecom_contas/guardrails.yaml new file mode 100644 index 0000000..9fe094a --- /dev/null +++ b/config/agents/telecom_contas/guardrails.yaml @@ -0,0 +1,8 @@ +input: + - code: MSK + enabled: true + - code: VLOOP + enabled: true +output: + - code: REVPREC + enabled: true diff --git a/config/agents/telecom_contas/judges.yaml b/config/agents/telecom_contas/judges.yaml new file mode 100644 index 0000000..62fc7c7 --- /dev/null +++ b/config/agents/telecom_contas/judges.yaml @@ -0,0 +1,7 @@ +judges: + - name: response_quality + enabled: true + threshold: 0.7 + - name: groundedness + enabled: true + threshold: 0.6 diff --git a/config/agents/telecom_contas/prompt_policy.yaml b/config/agents/telecom_contas/prompt_policy.yaml new file mode 100644 index 0000000..42732c4 --- /dev/null +++ b/config/agents/telecom_contas/prompt_policy.yaml @@ -0,0 +1,6 @@ +id: telecom_contas_prompt_policy +version: 1 +description: Prompt base isolado do agente de telecom/contas. +system_prefix: | + Você é um agente corporativo de atendimento telecom especializado em faturas, produtos, VAS e suporte. + Seja claro, objetivo e não prometa execução operacional sem ferramenta ou confirmação válida. diff --git a/config/guardrails.yaml b/config/guardrails.yaml new file mode 100644 index 0000000..9fe094a --- /dev/null +++ b/config/guardrails.yaml @@ -0,0 +1,8 @@ +input: + - code: MSK + enabled: true + - code: VLOOP + enabled: true +output: + - code: REVPREC + enabled: true diff --git a/config/identity.yaml b/config/identity.yaml new file mode 100644 index 0000000..7169892 --- /dev/null +++ b/config/identity.yaml @@ -0,0 +1,79 @@ +identity: + version: "2" + required: + - session_key + keys: + customer_key: + description: Cliente/assinante canônico. + sources: + - business_context.customer_key + - customer_key + - customer.customerId + - customer.id + - customer.msisdn + - customer.document + - customer.cpf + - msisdn + - customer_id + - cpf_hash + - document_hash + - user_id + contract_key: + description: Contrato, linha, conta, asset, chamado ou reclamação principal. + sources: + - business_context.contract_key + - contract_key + - contract_id + - account_id + - asset_id + - complaint_id + - complaintProtocol + - complaint.complaintProtocol + - complaint.protocol + - complaint.protocol_id + - complaint.id + - transactionId + - transaction_id + - protocolo + - protocol_id + interaction_key: + description: Protocolo, reclamação, chamado, call id ou message id. + sources: + - business_context.interaction_key + - interaction_key + - protocol_id + - protocolo + - complaint_id + - complaintProtocol + - complaint.complaintProtocol + - complaint.protocol + - complaint.protocol_id + - complaint.id + - transactionId + - transaction_id + - ticket_id + - ura_call_id + - call_id + - message_id + account_key: + description: Conta comercial ou conta de cobrança. + sources: + - business_context.account_key + - account_key + - billing_account_id + - account_id + resource_key: + description: Recurso específico como linha, produto, asset ou serviço. + sources: + - business_context.resource_key + - resource_key + - msisdn + - asset_id + - product_id + session_key: + description: Sessão técnica estável escopada por tenant/agente. + sources: + - business_context.session_key + - session_key + - conversation_key + - session_id diff --git a/config/judges.yaml b/config/judges.yaml new file mode 100644 index 0000000..62fc7c7 --- /dev/null +++ b/config/judges.yaml @@ -0,0 +1,7 @@ +judges: + - name: response_quality + enabled: true + threshold: 0.7 + - name: groundedness + enabled: true + threshold: 0.6 diff --git a/config/mcp_parameter_mapping.yaml b/config/mcp_parameter_mapping.yaml new file mode 100644 index 0000000..343e818 --- /dev/null +++ b/config/mcp_parameter_mapping.yaml @@ -0,0 +1,51 @@ +defaults: {} + +tools: + consultar_reclamacao: + map: + interaction_key: protocol_id + customer_key: customer_key + consultar_cliente_backoffice: + map: + customer_key: customer_key + contract_key: contract_key + session_key: session_key + registrar_acao_backoffice: + map: + interaction_key: protocol_id + session_key: operator_session + consultar_siebel_caso: + map: + interaction_key: protocol_id + customer_key: customer_key + registrar_acao_siebel: + map: + interaction_key: protocol_id + session_key: operator_session + consultar_imdb_cliente: + map: + customer_key: customer_key + contract_key: contract_key + session_key: session_key + consultar_speech_analytics: + map: + interaction_key: protocol_id + customer_key: customer_key + consultar_tais_kb: + map: + interaction_key: protocol_id + customer_key: customer_key + consultar_abrt: + map: + customer_key: customer_key + interaction_key: protocol_id + consultar_portabilidade: + map: + customer_key: customer_key + contract_key: contract_key + buscar_templates_emulador: + map: + interaction_key: protocol_id + gerar_rascunho_emulador: + map: + interaction_key: protocol_id diff --git a/config/mcp_servers.docker.yaml b/config/mcp_servers.docker.yaml new file mode 100644 index 0000000..e374846 --- /dev/null +++ b/config/mcp_servers.docker.yaml @@ -0,0 +1,6 @@ +servers: + backoffice: + transport: http + endpoint: http://backoffice-mcp:8010 + enabled: true + description: MCP Server Backoffice via docker-compose. diff --git a/config/mcp_servers.yaml b/config/mcp_servers.yaml new file mode 100644 index 0000000..6acbc77 --- /dev/null +++ b/config/mcp_servers.yaml @@ -0,0 +1,7 @@ +servers: + backoffice: + description: Servidor MCP/HTTP de backoffice. Endpoint local padrão do MCP Server. + enabled: true + transport: http + endpoint: http://localhost:8010 + timeout_seconds: 20 diff --git a/config/prompt_policy.yaml b/config/prompt_policy.yaml new file mode 100644 index 0000000..af4398f --- /dev/null +++ b/config/prompt_policy.yaml @@ -0,0 +1,19 @@ +tone: + style: "claro, objetivo, empático" + forbidden_phrases: + - "procure atendimento humano" +vocabulary: + preferred: + fatura: "fatura" + contestacao: "contestação" +intents: + billing_agent: + - fatura + - boleto + - cobrança + - segunda via + product_agent: + - plano + - produto + - oferta + - serviço diff --git a/config/routing.yaml b/config/routing.yaml new file mode 100644 index 0000000..c7e3505 --- /dev/null +++ b/config/routing.yaml @@ -0,0 +1,120 @@ +router: + mode: router + fallback_agent: backoffice_agent + confidence_threshold: 0.60 + allow_handoff: false + +state_policies: + - state: BACKOFFICE_ACTIVE + agent: backoffice_agent + description: Mantém confirmações e complementos dentro do agente Backoffice/ANATEL nativo. + - state: WAITING_BACKOFFICE_IDENTIFIER + agent: backoffice_agent + description: Mantém mensagens curtas com protocolo/chamado/reclamação dentro do mesmo agente. + - state: BACKOFFICE_WAITING_ACTION_TEXT + agent: backoffice_agent + description: Solicita texto de ação antes de registrar operação em sistema externo. + +intents: + - name: backoffice_anatel_triage + domain: backoffice + agent: backoffice_agent + description: Triagem completa de demanda de backoffice, reclamação, protocolo, ANATEL ou solicitação operacional. + priority: 10 + next_state: BACKOFFICE_ACTIVE + mcp_tools: + - consultar_reclamacao + - consultar_cliente_backoffice + - consultar_siebel_caso + - consultar_imdb_cliente + - consultar_speech_analytics + - consultar_tais_kb + - consultar_abrt + - consultar_portabilidade + keywords: + - backoffice + - bko + - anatel + - reclamação + - reclamacao + - protocolo + - atendimento + - chamado + - demanda + - pendência + - pendencia + - contestação + - contestacao + examples: + - Preciso analisar uma reclamação da ANATEL. + - Consultar protocolo do cliente. + - Verificar pendência de atendimento. + + - name: backoffice_customer_lookup + domain: backoffice + agent: backoffice_agent + description: Consulta de cliente, contrato, linha, CPF/CNPJ mascarado, protocolo ou solicitação operacional. + priority: 20 + next_state: BACKOFFICE_ACTIVE + mcp_tools: + - consultar_cliente_backoffice + - consultar_imdb_cliente + - consultar_portabilidade + keywords: + - cliente + - contrato + - linha + - msisdn + - cpf + - cnpj + - cadastro + - conta + examples: + - Consultar dados do cliente. + - Verificar contrato da linha. + + - name: backoffice_response_emulator + domain: backoffice + agent: backoffice_agent + description: Geração ou consulta de rascunho/resposta para emulador de resposta ANATEL. + priority: 25 + next_state: BACKOFFICE_ACTIVE + mcp_tools: + - consultar_reclamacao + - buscar_templates_emulador + - gerar_rascunho_emulador + keywords: + - emulador + - rascunho + - minuta + - resposta + - template + - anatel + examples: + - Gerar rascunho de resposta da ANATEL. + - Buscar template para responder reclamação. + + - name: backoffice_action_register + domain: backoffice + agent: backoffice_agent + description: Registro de ação, parecer, conclusão, encaminhamento ou atualização de demanda. + priority: 30 + next_state: BACKOFFICE_ACTIVE + mcp_tools: + - consultar_reclamacao + - consultar_siebel_caso + - registrar_acao_backoffice + - registrar_acao_siebel + keywords: + - registrar + - concluir + - atualizar + - parecer + - encaminhar + - resolver + - finalizar + - observação + - observacao + examples: + - Registrar parecer no protocolo. + - Atualizar o chamado com a conclusão. diff --git a/config/tools.yaml b/config/tools.yaml new file mode 100644 index 0000000..07293d8 --- /dev/null +++ b/config/tools.yaml @@ -0,0 +1,62 @@ +tools: + consultar_reclamacao: + description: Consulta reclamação/protocolo de backoffice/ANATEL. + mcp_server: backoffice + enabled: true + args_schema: { protocol_id: string, customer_key: string, interaction_key: string } + consultar_cliente_backoffice: + description: Consulta contexto operacional do cliente para backoffice. + mcp_server: backoffice + enabled: true + args_schema: { customer_key: string, contract_key: string, session_key: string } + registrar_acao_backoffice: + description: Registra ação operacional, parecer ou encaminhamento no sistema de backoffice. + mcp_server: backoffice + enabled: true + args_schema: { protocol_id: string, action_text: string, operator_session: string } + + consultar_siebel_caso: + description: Consulta caso/SR no Siebel. + mcp_server: backoffice + enabled: true + args_schema: { protocol_id: string, interaction_key: string, customer_key: string } + registrar_acao_siebel: + description: Registra ação, reclassificação ou fechamento no Siebel. + mcp_server: backoffice + enabled: true + args_schema: { protocol_id: string, action_text: string, operator_session: string } + consultar_imdb_cliente: + description: Consulta enriquecimento IMDB/PMID do cliente. + mcp_server: backoffice + enabled: true + args_schema: { customer_key: string, contract_key: string, session_key: string } + consultar_speech_analytics: + description: Consulta histórico/resumo do Speech Analytics. + mcp_server: backoffice + enabled: true + args_schema: { protocol_id: string, customer_key: string, interaction_key: string } + consultar_tais_kb: + description: Consulta TAIS Knowledge Base/RAG e templates. + mcp_server: backoffice + enabled: true + args_schema: { query: string, protocol_id: string, customer_key: string } + consultar_abrt: + description: Consulta ABRT associado ao cliente/caso. + mcp_server: backoffice + enabled: true + args_schema: { customer_key: string, protocol_id: string } + consultar_portabilidade: + description: Consulta status de portabilidade. + mcp_server: backoffice + enabled: true + args_schema: { customer_key: string, contract_key: string } + buscar_templates_emulador: + description: Busca templates/documentos para Response Emulator. + mcp_server: backoffice + enabled: true + args_schema: { protocol_id: string, query: string } + gerar_rascunho_emulador: + description: Gera rascunho de resposta do Response Emulator. + mcp_server: backoffice + enabled: true + args_schema: { protocol_id: string, selected_actions: array, operator_instructions: string } diff --git a/configs_original_develop/README.md b/configs_original_develop/README.md new file mode 100644 index 0000000..afae0ba --- /dev/null +++ b/configs_original_develop/README.md @@ -0,0 +1,4 @@ +# Introduction + <- Arquivos de configurações necessários (json,yalm, xml). + + \ No newline at end of file diff --git a/configs_original_develop/config.example.yaml b/configs_original_develop/config.example.yaml new file mode 100644 index 0000000..30b9231 --- /dev/null +++ b/configs_original_develop/config.example.yaml @@ -0,0 +1,32 @@ +# Example configuration file for Agent Microservice +# Copy this file to config.yaml and customize as needed + +# Application Settings +APP_NAME: "Agent Microservice" +VERSION: "1.0.0" +DEBUG: false + +# Server Settings +HOST: "0.0.0.0" +PORT: 8000 + +# LLM Configuration +LLM_PROVIDER: "openai" # Options: openai, anthropic +LLM_MODEL: "gpt-4" +LLM_TEMPERATURE: 0.7 +LLM_MAX_TOKENS: null # null for no limit, or specify a number + +# API Keys (NEVER commit actual keys!) +# Set these via environment variables instead +OPENAI_API_KEY: "your-openai-key-here" +ANTHROPIC_API_KEY: "your-anthropic-key-here" + +# Logging Configuration +LOG_LEVEL: "INFO" # Options: DEBUG, INFO, WARNING, ERROR, CRITICAL +LOG_FORMAT: "json" # Options: json, text + +# Agent Configuration (Optional) +AGENT_NAME: "Example Agent" +AGENT_DESCRIPTION: "An example agent built with LangGraph" +AGENT_SYSTEM_PROMPT: "You are a helpful AI assistant." +AGENT_MAX_ITERATIONS: 10 diff --git a/curl_test.txt b/curl_test.txt new file mode 100644 index 0000000..911bec0 --- /dev/null +++ b/curl_test.txt @@ -0,0 +1,53 @@ +curl --location 'http://localhost:8000/agent/process-and-stream' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "caseType": "anatel", + "origin": { + "sourceSystem": "turbina_odc", + "submittedBy": { + "userId": "f8052701", + "name": "Nicolas Silva", + "email": null + } + }, + "crmProtocol": "DS-987654321", + "ticketId": "ert", + "customer": { + "cpfCnpj": "06252533106", + "phones": [ + "62981152324" + ], + "msisdn": "62981152324", + "name": "Nicolas Ferreira da Silva", + "email": "nicolas.silva@exemplo.com.br", + "govBrSeal": null, + "odcCustomer": false, + "contumazCustomer": false, + "address": { + "cep": "01001-000", + "street": "Praca da Se", + "neighborhood": "Se", + "city": "Sao Paulo", + "state": "SP" + }, + "subscriber": { + "cpfCnpj": "06252533106", + "subscriberName": "Assinante Exemplo", + "contactPhone": "62981152324", + "contactName": "Nicolas Ferreira da Silva" + } + }, + "complaint": { + "complaintProtocol": "202603279001551", + "actionType": "nova", + "providerProtocol": null, + "inputChannel": "Anatel", + "service": "Celular Pós-pago", + "firstService": "Celular Pós-pago", + "modality": "Cobrança", + "motive": "Operadora liga ou envia mensagens indevidas de cobrança", + "description": "A TIM está me ligando cobrando planos que eu não assinei. Quero que parem", + "openedAt": "2026-03-27T11:45:00-03:00", + "dueAt": null + } +}' \ No newline at end of file diff --git a/ddl/README.md b/ddl/README.md new file mode 100644 index 0000000..c1aa672 --- /dev/null +++ b/ddl/README.md @@ -0,0 +1,3 @@ +# Introduction + DDLs para criação de tabelas ou views + \ No newline at end of file diff --git a/ddl/anatel_notas.sql b/ddl/anatel_notas.sql new file mode 100644 index 0000000..a08cb08 --- /dev/null +++ b/ddl/anatel_notas.sql @@ -0,0 +1,108 @@ +CREATE TABLE ANATEL_NOTAS_RAW ( + ID NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY, + PROTOCOLO VARCHAR2(20 CHAR), + IDT_SOLICITACAO VARCHAR2(20 CHAR), + ID_INSTANCIA VARCHAR2(20 CHAR), + ID_TAREFA VARCHAR2(20 CHAR), + STATUS VARCHAR2(20 CHAR), + DATA_DOCUMENTO TIMESTAMP, + DATA_EVENTO TIMESTAMP, + DATA_CADASTRO TIMESTAMP, + DATA_LIMITE DATE, + DATA_RESPOSTA TIMESTAMP, + SERVICO VARCHAR2(50 CHAR), + PRIMEIRO_SERVICO VARCHAR2(50 CHAR), + MODALIDADE VARCHAR2(120 CHAR), + MOTIVO VARCHAR2(150 CHAR), + ACAO VARCHAR2(30 CHAR), + QTD_REABERTURA NUMBER(6), + RESPONSAVEL_TABULACAO_D0 VARCHAR2(120 CHAR), + DATA_TABULACAO_D0 TIMESTAMP, + TELEFONE_CONTATO_FIXO VARCHAR2(60 CHAR), + TELEFONE_RECLAMADO VARCHAR2(80 CHAR), + UF_CLIENTE VARCHAR2(2 CHAR), + CIDADE_CLIENTE VARCHAR2(80 CHAR), + ENDERECO_CLIENTE VARCHAR2(200 CHAR), + BAIRRO_CLIENTE VARCHAR2(120 CHAR), + CPF_CNPJ_CLIENTE VARCHAR2(20 CHAR), + NOME_CLIENTE VARCHAR2(150 CHAR), + CPF_CNPJ_ASSINANTE VARCHAR2(20 CHAR), + NOME_ASSINANTE VARCHAR2(150 CHAR), + PERFIL_RESPONSAVEL VARCHAR2(80 CHAR), + RESPONSAVEL VARCHAR2(120 CHAR), + SUPERVISOR VARCHAR2(120 CHAR), + COORDENADOR VARCHAR2(120 CHAR), + DESCRICAO_RECLAMACAO CLOB, + RESPOSTA_ANATEL CLOB, + JUSTIFICATIVA VARCHAR2(120 CHAR), + SITUACAO VARCHAR2(30 CHAR), + PENDENCIA_FUTURA VARCHAR2(10 CHAR), + MOTIVO_PENDENCIA_FUTURA VARCHAR2(60 CHAR), + DATA_PENDENCIA_FUTURA TIMESTAMP, + CANAL_ENTRADA VARCHAR2(30 CHAR), + STATUS_PROCESSO VARCHAR2(30 CHAR), + DATA_FINALIZADO TIMESTAMP, + RETIDO VARCHAR2(10 CHAR), + MOTIVO_RETENCAO VARCHAR2(60 CHAR), + DATA_IDA DATE, + TIPO_PESSOA VARCHAR2(5 CHAR), + EMAIL_CLIENTE VARCHAR2(320 CHAR), + NUMERO_CRM VARCHAR2(40 CHAR), + MOTIVO_RECLAMACAO_1 VARCHAR2(80 CHAR), + MOTIVO_RECLAMACAO_2 VARCHAR2(120 CHAR), + MOTIVO_RECLAMACAO_3 VARCHAR2(120 CHAR), + MOTIVO_PROBLEMA_1 VARCHAR2(80 CHAR), + MOTIVO_PROBLEMA_2 VARCHAR2(120 CHAR), + MOTIVO_PROBLEMA_3 VARCHAR2(100 CHAR), + MOTIVO_SOLUCAO_1 VARCHAR2(80 CHAR), + MOTIVO_SOLUCAO_2 VARCHAR2(80 CHAR), + MOTIVO_SOLUCAO_3 VARCHAR2(100 CHAR), + MOTIVO_REINCIDENTE VARCHAR2(80 CHAR), + DETALHE_MOTIVO_REINCIDENTE VARCHAR2(80 CHAR), + SITUACAO_FOCUS VARCHAR2(50 CHAR), + ULTIMA_ITERACAO VARCHAR2(10 CHAR), + SUBSERVICO VARCHAR2(50 CHAR), + PROTOCOLO_PRESTADORA VARCHAR2(30 CHAR), + CEP_CLIENTE VARCHAR2(12 CHAR), + SERVICE_ID VARCHAR2(30 CHAR), + AVALIACAO VARCHAR2(30 CHAR), + NOTA NUMBER(2), + RISCO_NOTA_BAIXA NUMBER(5,3), + RESOLVIDO VARCHAR2(10 CHAR), + REALIZAR_INCENTIVO VARCHAR2(10 CHAR), + TELEFONE_WHATSAPP VARCHAR2(20 CHAR), + ID_PRONTA_PARA_CONTATO VARCHAR2(50 CHAR), + DATA_ENVIO_PODE_FALAR_AGORA TIMESTAMP, + DATA_RETORNO_FALAR_AGORA TIMESTAMP, + RETORNO_HORARIO_OPERACIONAL VARCHAR2(10 CHAR), + RETORNO_FALAR_AGORA VARCHAR2(50 CHAR), + CLIENTE_FAVORAVEL VARCHAR2(10 CHAR), + SATISFACAO_CLIENTE VARCHAR2(30 CHAR), + RECLAMACAO_CONSIDERADA VARCHAR2(50 CHAR), + SELO_GOV_BR VARCHAR2(20 CHAR) +); + +CREATE TABLE ANATEL_NOTAS_CHUNKS_RECLAMACAO_COHERE_3 ( + ID_CHUNK NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY, + ID NUMBER, -- referência para anatel_notas_raw.id + CHUNK_RECLAMACAO VARCHAR2(4096 CHAR), + EMBEDDING VECTOR NOT NULL, + NOTA NUMBER(2) +); + +CREATE TABLE ANATEL_NOTAS_CHUNKS_RESPOSTA_COHERE_3 ( + ID_CHUNK NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY, + ID NUMBER, -- referência para anatel_notas_raw.id + CHUNK_RESPOSTA VARCHAR2(4096 CHAR), + EMBEDDING VECTOR NOT NULL, + NOTA NUMBER(2) +); + +CREATE TABLE ANATEL_NOTAS_COHERE_4 ( + ID NUMBER, -- referência para anatel_notas_raw.id + RECLAMACAO VARCHAR2(4096 CHAR), + EMBEDDING_RECLAMACAO VECTOR NOT NULL, + CHUNK_RESPOSTA VARCHAR2(4096 CHAR), + RESPOSTA VECTOR NOT NULL, + NOTA NUMBER(2) +); \ No newline at end of file diff --git a/ddl/create-collections.sql b/ddl/create-collections.sql new file mode 100644 index 0000000..2e9af50 --- /dev/null +++ b/ddl/create-collections.sql @@ -0,0 +1,14 @@ +-- Criação da collection cases +DECLARE + collection SODA_Collection_T; +BEGIN + collection := DBMS_SODA.create_collection('cases'); +END; +/ +-- Criação da collection bo_memory +DECLARE + collection SODA_Collection_T; +BEGIN + collection := DBMS_SODA.create_collection('bo_memory'); +END; +/ \ No newline at end of file diff --git a/ddl/manual_anatel.sql b/ddl/manual_anatel.sql new file mode 100644 index 0000000..c844752 --- /dev/null +++ b/ddl/manual_anatel.sql @@ -0,0 +1,83 @@ +CREATE TABLE ANATEL_MANUAL_CHUNKS_RAW ( + CHUNK_ID VARCHAR2(128) NOT NULL, + CHUNK_PROFILE VARCHAR2(64) NOT NULL, + DOC_NAME VARCHAR2(255) NOT NULL, + PAGE_START NUMBER(10) NOT NULL, + PAGE_END NUMBER(10) NOT NULL, + SECTION_PATH CLOB, + CONTENT CLOB NOT NULL, + CONTENT_HASH VARCHAR2(64) NOT NULL, + MODALITY VARCHAR2(32) NOT NULL, + PARSER_BACKEND VARCHAR2(32) NOT NULL, + TOKEN_COUNT NUMBER(10) NOT NULL, + SOURCE_KIND VARCHAR2(64) NOT NULL, + CREATED_AT TIMESTAMP WITH TIME ZONE, + METADATA CLOB, + EMBEDDING_INPUT CLOB, + + CONSTRAINT PK_ANATEL_MANUAL_CHUNKS_RAW + PRIMARY KEY (CHUNK_ID), + + CONSTRAINT CK_ANATEL_MANUAL_CHUNKS_RAW_METADATA_JSON + CHECK (METADATA IS JSON) +); + +CREATE TABLE ANATEL_MANUAL_CHUNKS_COHERE_3 ( + CHUNK_ID VARCHAR2(128) NOT NULL, + CHUNK_PROFILE VARCHAR2(64) NOT NULL, + DOC_NAME VARCHAR2(255) NOT NULL, + PAGE_START NUMBER(10) NOT NULL, + PAGE_END NUMBER(10) NOT NULL, + SECTION_PATH CLOB, + CONTENT CLOB NOT NULL, + CONTENT_HASH VARCHAR2(64) NOT NULL, + MODALITY VARCHAR2(32) NOT NULL, + PARSER_BACKEND VARCHAR2(32) NOT NULL, + TOKEN_COUNT NUMBER(10) NOT NULL, + SOURCE_KIND VARCHAR2(64) NOT NULL, + CREATED_AT TIMESTAMP WITH TIME ZONE, + METADATA CLOB, + EMBEDDING_INPUT CLOB, + EMBEDDING_TEXT CLOB NOT NULL, + EMBEDDING VECTOR(1024, FLOAT32) NOT NULL, + + CONSTRAINT PK_AMC_COHERE_3 + PRIMARY KEY (CHUNK_ID), + + CONSTRAINT FK_AMC_COHERE_3_RAW + FOREIGN KEY (CHUNK_ID) + REFERENCES ANATEL_MANUAL_CHUNKS_RAW (CHUNK_ID), + + CONSTRAINT CK_AMC_COHERE_3_METADATA_JSON + CHECK (METADATA IS JSON) +); + +CREATE TABLE ANATEL_MANUAL_CHUNKS_COHERE_4 ( + CHUNK_ID VARCHAR2(128) NOT NULL, + CHUNK_PROFILE VARCHAR2(64) NOT NULL, + DOC_NAME VARCHAR2(255) NOT NULL, + PAGE_START NUMBER(10) NOT NULL, + PAGE_END NUMBER(10) NOT NULL, + SECTION_PATH CLOB, + CONTENT CLOB NOT NULL, + CONTENT_HASH VARCHAR2(64) NOT NULL, + MODALITY VARCHAR2(32) NOT NULL, + PARSER_BACKEND VARCHAR2(32) NOT NULL, + TOKEN_COUNT NUMBER(10) NOT NULL, + SOURCE_KIND VARCHAR2(64) NOT NULL, + CREATED_AT TIMESTAMP WITH TIME ZONE, + METADATA CLOB, + EMBEDDING_INPUT CLOB, + EMBEDDING_TEXT CLOB NOT NULL, + EMBEDDING VECTOR(1536, FLOAT32) NOT NULL, + + CONSTRAINT PK_AMC_COHERE_4 + PRIMARY KEY (CHUNK_ID), + + CONSTRAINT FK_AMC_COHERE_4_RAW + FOREIGN KEY (CHUNK_ID) + REFERENCES ANATEL_MANUAL_CHUNKS_RAW (CHUNK_ID), + + CONSTRAINT CK_AMC_COHERE_4_METADATA_JSON + CHECK (METADATA IS JSON) +); \ No newline at end of file diff --git a/ddl/templates.sql b/ddl/templates.sql new file mode 100644 index 0000000..fded3ef --- /dev/null +++ b/ddl/templates.sql @@ -0,0 +1,30 @@ +CREATE TABLE TEMPLATES_RAW ( + ID NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY, + ITEM VARCHAR2(250 CHAR), + QUANDO_USAR VARCHAR2(500 CHAR), + INFORMACOES_OBRIGATORIAS VARCHAR2(250 CHAR), + SUGESTAO_PARA_COMPOR_RESPOSTA VARCHAR2(4096 CHAR), + MENU VARCHAR2(100 CHAR) +); + +CREATE TABLE TEMPLATES_COHERE_3 ( + ID NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY, + ITEM VARCHAR2(250 CHAR), + QUANDO_USAR VARCHAR2(500 CHAR), + INFORMACOES_OBRIGATORIAS VARCHAR2(250 CHAR), + SUGESTAO_PARA_COMPOR_RESPOSTA VARCHAR2(4096 CHAR), + MENU VARCHAR2(100 CHAR), + EMBEDDING_TEXT VARCHAR2(1024 CHAR), + EMBEDDING VECTOR NOT NULL +); + +CREATE TABLE TEMPLATES_COHERE_4 ( + ID NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY, + ITEM VARCHAR2(250 CHAR), + QUANDO_USAR VARCHAR2(500 CHAR), + INFORMACOES_OBRIGATORIAS VARCHAR2(250 CHAR), + SUGESTAO_PARA_COMPOR_RESPOSTA VARCHAR2(4096 CHAR), + MENU VARCHAR2(100 CHAR), + EMBEDDING_TEXT VARCHAR2(1024 CHAR), + EMBEDDING VECTOR NOT NULL +); \ No newline at end of file diff --git a/ddl_original_develop/README.md b/ddl_original_develop/README.md new file mode 100644 index 0000000..c1aa672 --- /dev/null +++ b/ddl_original_develop/README.md @@ -0,0 +1,3 @@ +# Introduction + DDLs para criação de tabelas ou views + \ No newline at end of file diff --git a/ddl_original_develop/anatel_notas.sql b/ddl_original_develop/anatel_notas.sql new file mode 100644 index 0000000..a08cb08 --- /dev/null +++ b/ddl_original_develop/anatel_notas.sql @@ -0,0 +1,108 @@ +CREATE TABLE ANATEL_NOTAS_RAW ( + ID NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY, + PROTOCOLO VARCHAR2(20 CHAR), + IDT_SOLICITACAO VARCHAR2(20 CHAR), + ID_INSTANCIA VARCHAR2(20 CHAR), + ID_TAREFA VARCHAR2(20 CHAR), + STATUS VARCHAR2(20 CHAR), + DATA_DOCUMENTO TIMESTAMP, + DATA_EVENTO TIMESTAMP, + DATA_CADASTRO TIMESTAMP, + DATA_LIMITE DATE, + DATA_RESPOSTA TIMESTAMP, + SERVICO VARCHAR2(50 CHAR), + PRIMEIRO_SERVICO VARCHAR2(50 CHAR), + MODALIDADE VARCHAR2(120 CHAR), + MOTIVO VARCHAR2(150 CHAR), + ACAO VARCHAR2(30 CHAR), + QTD_REABERTURA NUMBER(6), + RESPONSAVEL_TABULACAO_D0 VARCHAR2(120 CHAR), + DATA_TABULACAO_D0 TIMESTAMP, + TELEFONE_CONTATO_FIXO VARCHAR2(60 CHAR), + TELEFONE_RECLAMADO VARCHAR2(80 CHAR), + UF_CLIENTE VARCHAR2(2 CHAR), + CIDADE_CLIENTE VARCHAR2(80 CHAR), + ENDERECO_CLIENTE VARCHAR2(200 CHAR), + BAIRRO_CLIENTE VARCHAR2(120 CHAR), + CPF_CNPJ_CLIENTE VARCHAR2(20 CHAR), + NOME_CLIENTE VARCHAR2(150 CHAR), + CPF_CNPJ_ASSINANTE VARCHAR2(20 CHAR), + NOME_ASSINANTE VARCHAR2(150 CHAR), + PERFIL_RESPONSAVEL VARCHAR2(80 CHAR), + RESPONSAVEL VARCHAR2(120 CHAR), + SUPERVISOR VARCHAR2(120 CHAR), + COORDENADOR VARCHAR2(120 CHAR), + DESCRICAO_RECLAMACAO CLOB, + RESPOSTA_ANATEL CLOB, + JUSTIFICATIVA VARCHAR2(120 CHAR), + SITUACAO VARCHAR2(30 CHAR), + PENDENCIA_FUTURA VARCHAR2(10 CHAR), + MOTIVO_PENDENCIA_FUTURA VARCHAR2(60 CHAR), + DATA_PENDENCIA_FUTURA TIMESTAMP, + CANAL_ENTRADA VARCHAR2(30 CHAR), + STATUS_PROCESSO VARCHAR2(30 CHAR), + DATA_FINALIZADO TIMESTAMP, + RETIDO VARCHAR2(10 CHAR), + MOTIVO_RETENCAO VARCHAR2(60 CHAR), + DATA_IDA DATE, + TIPO_PESSOA VARCHAR2(5 CHAR), + EMAIL_CLIENTE VARCHAR2(320 CHAR), + NUMERO_CRM VARCHAR2(40 CHAR), + MOTIVO_RECLAMACAO_1 VARCHAR2(80 CHAR), + MOTIVO_RECLAMACAO_2 VARCHAR2(120 CHAR), + MOTIVO_RECLAMACAO_3 VARCHAR2(120 CHAR), + MOTIVO_PROBLEMA_1 VARCHAR2(80 CHAR), + MOTIVO_PROBLEMA_2 VARCHAR2(120 CHAR), + MOTIVO_PROBLEMA_3 VARCHAR2(100 CHAR), + MOTIVO_SOLUCAO_1 VARCHAR2(80 CHAR), + MOTIVO_SOLUCAO_2 VARCHAR2(80 CHAR), + MOTIVO_SOLUCAO_3 VARCHAR2(100 CHAR), + MOTIVO_REINCIDENTE VARCHAR2(80 CHAR), + DETALHE_MOTIVO_REINCIDENTE VARCHAR2(80 CHAR), + SITUACAO_FOCUS VARCHAR2(50 CHAR), + ULTIMA_ITERACAO VARCHAR2(10 CHAR), + SUBSERVICO VARCHAR2(50 CHAR), + PROTOCOLO_PRESTADORA VARCHAR2(30 CHAR), + CEP_CLIENTE VARCHAR2(12 CHAR), + SERVICE_ID VARCHAR2(30 CHAR), + AVALIACAO VARCHAR2(30 CHAR), + NOTA NUMBER(2), + RISCO_NOTA_BAIXA NUMBER(5,3), + RESOLVIDO VARCHAR2(10 CHAR), + REALIZAR_INCENTIVO VARCHAR2(10 CHAR), + TELEFONE_WHATSAPP VARCHAR2(20 CHAR), + ID_PRONTA_PARA_CONTATO VARCHAR2(50 CHAR), + DATA_ENVIO_PODE_FALAR_AGORA TIMESTAMP, + DATA_RETORNO_FALAR_AGORA TIMESTAMP, + RETORNO_HORARIO_OPERACIONAL VARCHAR2(10 CHAR), + RETORNO_FALAR_AGORA VARCHAR2(50 CHAR), + CLIENTE_FAVORAVEL VARCHAR2(10 CHAR), + SATISFACAO_CLIENTE VARCHAR2(30 CHAR), + RECLAMACAO_CONSIDERADA VARCHAR2(50 CHAR), + SELO_GOV_BR VARCHAR2(20 CHAR) +); + +CREATE TABLE ANATEL_NOTAS_CHUNKS_RECLAMACAO_COHERE_3 ( + ID_CHUNK NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY, + ID NUMBER, -- referência para anatel_notas_raw.id + CHUNK_RECLAMACAO VARCHAR2(4096 CHAR), + EMBEDDING VECTOR NOT NULL, + NOTA NUMBER(2) +); + +CREATE TABLE ANATEL_NOTAS_CHUNKS_RESPOSTA_COHERE_3 ( + ID_CHUNK NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY, + ID NUMBER, -- referência para anatel_notas_raw.id + CHUNK_RESPOSTA VARCHAR2(4096 CHAR), + EMBEDDING VECTOR NOT NULL, + NOTA NUMBER(2) +); + +CREATE TABLE ANATEL_NOTAS_COHERE_4 ( + ID NUMBER, -- referência para anatel_notas_raw.id + RECLAMACAO VARCHAR2(4096 CHAR), + EMBEDDING_RECLAMACAO VECTOR NOT NULL, + CHUNK_RESPOSTA VARCHAR2(4096 CHAR), + RESPOSTA VECTOR NOT NULL, + NOTA NUMBER(2) +); \ No newline at end of file diff --git a/ddl_original_develop/create-collections.sql b/ddl_original_develop/create-collections.sql new file mode 100644 index 0000000..2e9af50 --- /dev/null +++ b/ddl_original_develop/create-collections.sql @@ -0,0 +1,14 @@ +-- Criação da collection cases +DECLARE + collection SODA_Collection_T; +BEGIN + collection := DBMS_SODA.create_collection('cases'); +END; +/ +-- Criação da collection bo_memory +DECLARE + collection SODA_Collection_T; +BEGIN + collection := DBMS_SODA.create_collection('bo_memory'); +END; +/ \ No newline at end of file diff --git a/ddl_original_develop/manual_anatel.sql b/ddl_original_develop/manual_anatel.sql new file mode 100644 index 0000000..c844752 --- /dev/null +++ b/ddl_original_develop/manual_anatel.sql @@ -0,0 +1,83 @@ +CREATE TABLE ANATEL_MANUAL_CHUNKS_RAW ( + CHUNK_ID VARCHAR2(128) NOT NULL, + CHUNK_PROFILE VARCHAR2(64) NOT NULL, + DOC_NAME VARCHAR2(255) NOT NULL, + PAGE_START NUMBER(10) NOT NULL, + PAGE_END NUMBER(10) NOT NULL, + SECTION_PATH CLOB, + CONTENT CLOB NOT NULL, + CONTENT_HASH VARCHAR2(64) NOT NULL, + MODALITY VARCHAR2(32) NOT NULL, + PARSER_BACKEND VARCHAR2(32) NOT NULL, + TOKEN_COUNT NUMBER(10) NOT NULL, + SOURCE_KIND VARCHAR2(64) NOT NULL, + CREATED_AT TIMESTAMP WITH TIME ZONE, + METADATA CLOB, + EMBEDDING_INPUT CLOB, + + CONSTRAINT PK_ANATEL_MANUAL_CHUNKS_RAW + PRIMARY KEY (CHUNK_ID), + + CONSTRAINT CK_ANATEL_MANUAL_CHUNKS_RAW_METADATA_JSON + CHECK (METADATA IS JSON) +); + +CREATE TABLE ANATEL_MANUAL_CHUNKS_COHERE_3 ( + CHUNK_ID VARCHAR2(128) NOT NULL, + CHUNK_PROFILE VARCHAR2(64) NOT NULL, + DOC_NAME VARCHAR2(255) NOT NULL, + PAGE_START NUMBER(10) NOT NULL, + PAGE_END NUMBER(10) NOT NULL, + SECTION_PATH CLOB, + CONTENT CLOB NOT NULL, + CONTENT_HASH VARCHAR2(64) NOT NULL, + MODALITY VARCHAR2(32) NOT NULL, + PARSER_BACKEND VARCHAR2(32) NOT NULL, + TOKEN_COUNT NUMBER(10) NOT NULL, + SOURCE_KIND VARCHAR2(64) NOT NULL, + CREATED_AT TIMESTAMP WITH TIME ZONE, + METADATA CLOB, + EMBEDDING_INPUT CLOB, + EMBEDDING_TEXT CLOB NOT NULL, + EMBEDDING VECTOR(1024, FLOAT32) NOT NULL, + + CONSTRAINT PK_AMC_COHERE_3 + PRIMARY KEY (CHUNK_ID), + + CONSTRAINT FK_AMC_COHERE_3_RAW + FOREIGN KEY (CHUNK_ID) + REFERENCES ANATEL_MANUAL_CHUNKS_RAW (CHUNK_ID), + + CONSTRAINT CK_AMC_COHERE_3_METADATA_JSON + CHECK (METADATA IS JSON) +); + +CREATE TABLE ANATEL_MANUAL_CHUNKS_COHERE_4 ( + CHUNK_ID VARCHAR2(128) NOT NULL, + CHUNK_PROFILE VARCHAR2(64) NOT NULL, + DOC_NAME VARCHAR2(255) NOT NULL, + PAGE_START NUMBER(10) NOT NULL, + PAGE_END NUMBER(10) NOT NULL, + SECTION_PATH CLOB, + CONTENT CLOB NOT NULL, + CONTENT_HASH VARCHAR2(64) NOT NULL, + MODALITY VARCHAR2(32) NOT NULL, + PARSER_BACKEND VARCHAR2(32) NOT NULL, + TOKEN_COUNT NUMBER(10) NOT NULL, + SOURCE_KIND VARCHAR2(64) NOT NULL, + CREATED_AT TIMESTAMP WITH TIME ZONE, + METADATA CLOB, + EMBEDDING_INPUT CLOB, + EMBEDDING_TEXT CLOB NOT NULL, + EMBEDDING VECTOR(1536, FLOAT32) NOT NULL, + + CONSTRAINT PK_AMC_COHERE_4 + PRIMARY KEY (CHUNK_ID), + + CONSTRAINT FK_AMC_COHERE_4_RAW + FOREIGN KEY (CHUNK_ID) + REFERENCES ANATEL_MANUAL_CHUNKS_RAW (CHUNK_ID), + + CONSTRAINT CK_AMC_COHERE_4_METADATA_JSON + CHECK (METADATA IS JSON) +); \ No newline at end of file diff --git a/ddl_original_develop/templates.sql b/ddl_original_develop/templates.sql new file mode 100644 index 0000000..fded3ef --- /dev/null +++ b/ddl_original_develop/templates.sql @@ -0,0 +1,30 @@ +CREATE TABLE TEMPLATES_RAW ( + ID NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY, + ITEM VARCHAR2(250 CHAR), + QUANDO_USAR VARCHAR2(500 CHAR), + INFORMACOES_OBRIGATORIAS VARCHAR2(250 CHAR), + SUGESTAO_PARA_COMPOR_RESPOSTA VARCHAR2(4096 CHAR), + MENU VARCHAR2(100 CHAR) +); + +CREATE TABLE TEMPLATES_COHERE_3 ( + ID NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY, + ITEM VARCHAR2(250 CHAR), + QUANDO_USAR VARCHAR2(500 CHAR), + INFORMACOES_OBRIGATORIAS VARCHAR2(250 CHAR), + SUGESTAO_PARA_COMPOR_RESPOSTA VARCHAR2(4096 CHAR), + MENU VARCHAR2(100 CHAR), + EMBEDDING_TEXT VARCHAR2(1024 CHAR), + EMBEDDING VECTOR NOT NULL +); + +CREATE TABLE TEMPLATES_COHERE_4 ( + ID NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY, + ITEM VARCHAR2(250 CHAR), + QUANDO_USAR VARCHAR2(500 CHAR), + INFORMACOES_OBRIGATORIAS VARCHAR2(250 CHAR), + SUGESTAO_PARA_COMPOR_RESPOSTA VARCHAR2(4096 CHAR), + MENU VARCHAR2(100 CHAR), + EMBEDDING_TEXT VARCHAR2(1024 CHAR), + EMBEDDING VECTOR NOT NULL +); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e8a651f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + backoffice-api: + build: . + container_name: backoffice-api + env_file: + - .env + environment: + BACKOFFICE_MCP_BASE_URL: http://backoffice-mcp:8010 + MCP_SERVERS_CONFIG: /app/config/mcp_servers.docker.yaml + MCP_TOOLS_CONFIG: /app/config/tools.yaml + MCP_PARAMETER_MAPPING_CONFIG: /app/config/mcp_parameter_mapping.yaml + ports: + - "8000:8000" + depends_on: + - backoffice-mcp + + backoffice-mcp: + build: . + container_name: backoffice-mcp + env_file: + - .env.backoffice_mcp.example + command: > + sh -c 'uvicorn mcp_servers.backoffice_mcp_server.main:app + --host $${BACKOFFICE_MCP_HOST:-0.0.0.0} + --port $${BACKOFFICE_MCP_PORT:-8010} + --log-level $${BACKOFFICE_MCP_LOG_LEVEL:-info}' + ports: + - "8010:8010" diff --git a/docs/AJUSTES_IC_NOC_GRL_OCI_OPENAI.md b/docs/AJUSTES_IC_NOC_GRL_OCI_OPENAI.md new file mode 100644 index 0000000..e7322b4 --- /dev/null +++ b/docs/AJUSTES_IC_NOC_GRL_OCI_OPENAI.md @@ -0,0 +1,39 @@ +# Ajustes IC/NOC/GRL e LLM_PROVIDER=oci_openai + +Esta versão corrige dois pontos do runtime nativo do backoffice: + +1. **IC/NOC/GRL padronizados pelo framework** + - O workflow nativo agora emite eventos pelo `AgentObserver` do framework. + - Eventos legados `AGA.*`, `NOC.*` e `GRL.*` capturados dos nós originais são reemitidos pela ponte `_bridge_legacy_ics`. + - O runtime emite explicitamente eventos de início, conclusão e falha do workflow. + - Os guardrails emitem GRL explícitos para input, output supervisor e output guardrails. + +2. **Suporte ao `LLM_PROVIDER=oci_openai`** + - `src/core/config.py` agora aceita `oci_openai` no validador local usado pelos nós do backoffice original. + - `.env.example` usa `LLM_PROVIDER=oci_openai` por padrão e documenta as variáveis esperadas para OCI/OpenAI-compatible. + +## Eventos adicionados no workflow nativo + +- `IC.BACKOFFICE_WORKFLOW_STARTED` +- `IC.BACKOFFICE_WORKFLOW_COMPLETED` +- `IC.BACKOFFICE_NODE_STARTED` +- `IC.BACKOFFICE_NODE_COMPLETED` +- `NOC.001` workflow started +- `NOC.006` workflow completed +- `NOC.009` workflow/node failed +- `GRL.001` input guardrails started +- `GRL.002` input guardrails completed +- `GRL.003` input blocked +- `GRL.004` output supervisor started +- `GRL.005` output supervisor completed +- `GRL.006` output supervisor blocked/handover +- `GRL.007` output guardrails started +- `GRL.008` output guardrails completed +- `GRL.009` output blocked/sanitized + +## Arquivos alterados + +- `app/workflows/backoffice_native_runtime.py` +- `src/core/config.py` +- `.env.example` +- `tools/validate_parity.py` diff --git a/docs/ATUALIZACAO_TEMPLATE_ANALYTICS_OUTPUT_SUPERVISOR.md b/docs/ATUALIZACAO_TEMPLATE_ANALYTICS_OUTPUT_SUPERVISOR.md new file mode 100644 index 0000000..d81efdf --- /dev/null +++ b/docs/ATUALIZACAO_TEMPLATE_ANALYTICS_OUTPUT_SUPERVISOR.md @@ -0,0 +1,95 @@ +# Atualização do Template Backend — Analytics, Observer, NOC/GRL e OutputSupervisor + +Esta versão do `agent_template_backend` foi atualizada para consumir as novidades transportadas para o `agent_framework`. + +## 1. Analytics e Pub/Sub + +O backend não chama mais diretamente apenas o publisher antigo de eventos. Agora ele cria um `AnalyticsPublisher`: + +```python +from agent_framework.analytics.factory import create_analytics_publisher +from agent_framework.observability.observer import AgentObserver + +analytics = create_analytics_publisher(settings) +observer = AgentObserver(analytics=analytics) +``` + +Com isso, o mesmo backend pode publicar em: + +- OCI Streaming +- GCP Pub/Sub +- CompositePublisher, quando `ANALYTICS_PROVIDERS=oci_streaming,pubsub` +- Noop, quando analytics estiver desligado + +## 2. Configuração mínima + +```env +ENABLE_ANALYTICS=true +ANALYTICS_PROVIDERS=pubsub +GCP_PUBSUB_TOPIC_PATH=projects//topics/ +GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json +``` + +Para publicar simultaneamente em OCI Streaming e GCP Pub/Sub: + +```env +ENABLE_ANALYTICS=true +ANALYTICS_PROVIDERS=oci_streaming,pubsub +ENABLE_OCI_STREAMING=true +OCI_STREAM_ENDPOINT= +OCI_STREAM_OCID= +GCP_PUBSUB_TOPIC_PATH=projects//topics/ +``` + +## 3. Observer corporativo + +O workflow recebeu emissão automática dos principais eventos corporativos: + +- `NOC.001`: início do workflow +- `NOC.005`: exceção fatal no workflow +- `NOC.006`: fim do workflow antes da resposta final +- `IC.AGENT_COMPLETED`: evento informacional de conclusão +- `GRL.001` a `GRL.009`: emitidos pelo `OutputSupervisor` + +## 4. OutputSupervisor + +Foi inserido um novo nó LangGraph: + +```text +agent -> output_supervisor -> output_guardrails -> judge -> supervisor_review -> persist +``` + +O `OutputSupervisor` não substitui o supervisor de roteamento. Ele valida a saída candidata do agente usando o contrato corporativo: + +- `allow` +- `sanitize` +- `retry` +- `block` +- `handover` +- `observe` + +Para compatibilidade com os guardrails já existentes, o template inclui o adapter `LegacyOutputGuardrailRail`, que converte decisões antigas `allowed=True/False` para `RailAction`. + +## 5. Campos adicionados ao AgentState + +```python +supervisor_action: str +supervisor_guidance: str +supervisor_attempt: int +supervisor_handover_reason: str +output_supervisor_results: list[dict] +output_guardrails_already_applied: bool +``` + +## 6. Arquivos alterados + +- `agent_template_backend/app/main.py` +- `agent_template_backend/app/workflows/agent_graph.py` +- `agent_template_backend/app/state.py` +- `agent_template_backend/.env` +- `agent_template_backend/requirements.txt` +- `agent_framework/src/agent_framework/config/settings.py` + +## 7. Observação importante + +O `OutputSupervisor` roda os guardrails de saída por meio do adapter legado e marca `output_guardrails_already_applied=True`. Assim o nó `output_guardrails` permanece no grafo para compatibilidade, mas evita reexecutar a mesma validação quando o supervisor já aplicou os rails. diff --git a/docs/COMO_USAR_IC_NOC_GRL_NO_TEMPLATE.md b/docs/COMO_USAR_IC_NOC_GRL_NO_TEMPLATE.md new file mode 100644 index 0000000..83975af --- /dev/null +++ b/docs/COMO_USAR_IC_NOC_GRL_NO_TEMPLATE.md @@ -0,0 +1,45 @@ +# Como usar IC, NOC e GRL no Template Backend + +## IC — Item de Controle + +Use IC para registrar eventos de negócio relevantes. + +```python +await observer.emit_ic( + "IC.FATURA_CONSULTADA", + {"session_id": session_id, "invoice_id": invoice_id}, + component="billing_agent", +) +``` + +## NOC — Evento operacional + +Use NOC para saúde técnica, latência, erros e checkpoints operacionais. + +```python +await observer.emit_noc( + "003", + {"session_id": session_id, "resourceName": "ADB", "latencyMs": 120}, + component="repository", +) +``` + +## GRL — Evento de guardrail + +Normalmente o framework emite GRL automaticamente. Use manualmente apenas para +rails customizados dentro do agente. + +```python +await observer.emit_grl( + "OBSERVE", + {"session_id": session_id, "rail_code": "CUSTOM_POLICY"}, + component="custom_rail", +) +``` + +## Onde já existe no template + +- `app/workflows/agent_graph.py` emite IC/NOC no ciclo do workflow. +- `app/agents/runtime.py` emite IC para MCP/tools. +- `app/agents/*_agent.py` contém exemplos dentro do método `run()`. +- `app/examples/` contém exemplos isolados. diff --git a/docs/CORRECAO_ENV_MCP_TIM_CLIENTS.md b/docs/CORRECAO_ENV_MCP_TIM_CLIENTS.md new file mode 100644 index 0000000..0095474 --- /dev/null +++ b/docs/CORRECAO_ENV_MCP_TIM_CLIENTS.md @@ -0,0 +1,88 @@ +# Correção: .env original TIM + MCP Server + URLs + +## Problema corrigido + +O pacote anterior ainda tratava o MCP Server como um backend REST genérico baseado em `BACKOFFICE_MCP_REST_BASE_URL`. Quando essa variável não existia, uma chamada HTTP podia ser montada com URL vazia, gerando erro como: + +```text +Request URL is missing an 'http://' or 'https://' protocol. +``` + +Além disso, o `.env` original do backoffice `develop` usa variáveis específicas: + +```text +PMID_API_HOST +SIEBEL_API_HOST +SPEECH_PREDICTION_BASE_URL +SPEECH_HISTORY_BASE_URL +TAIS_DB_DSN +TAIS_GENAI_ENDPOINT +... +``` + +Essas variáveis não devem ser substituídas por uma URL REST genérica. + +## Ajustes implementados + +1. `src/core/config.py` agora carrega explicitamente o `.env` da raiz do projeto. +2. `config/mcp_servers.yaml` agora usa endpoint literal seguro: + +```yaml +endpoint: http://localhost:8010 +``` + +Isso evita depender de expansão YAML `${VAR:default}` caso o `MCPToolRouter` do framework não suporte esse formato. + +3. `mcp_servers/backoffice_mcp_server/settings.py` agora aceita: + +- variáveis `BACKOFFICE_MCP_*`, quando existirem; +- variáveis originais TIM/develop, como `PMID_API_HOST`, `SIEBEL_API_HOST`, `SPEECH_*`, `TAIS_*`. + +4. O default do MCP Server passou a ser: + +```text +BACKOFFICE_MCP_BACKEND_TYPE=tim_clients +BACKOFFICE_MCP_USE_MOCK=false +``` + +Assim o MCP não mascara erro usando mock quando o `.env` real está presente. + +5. As tools IMDB/PMID, Speech, TAIS, ABRT e Portabilidade passam a chamar os clients originais do projeto quando `backend_type=tim_clients`. + +## Como testar + +Suba o MCP Server: + +```bash +uvicorn mcp_servers.backoffice_mcp_server.main:app --host 0.0.0.0 --port 8010 --reload +``` + +Confirme o health: + +```bash +curl http://localhost:8010/health +``` + +O retorno deve indicar: + +```json +{ + "use_mock": false, + "backend_type": "tim_clients", + "tim_clients_configured": true +} +``` + +Depois suba o backend principal: + +```bash +uvicorn app.main:app --host 0.0.0.0 --port 9000 --reload +``` + +Teste a lista de tools via backend: + +```bash +curl http://localhost:9000/debug/mcp/tools +``` + +Se o MCP estiver ativo, essa chamada deve chegar no processo da porta `8010`. diff --git a/docs/CORRECAO_EXTRACAO_CPF_TEXTO_LIVRE.md b/docs/CORRECAO_EXTRACAO_CPF_TEXTO_LIVRE.md new file mode 100644 index 0000000..895136f --- /dev/null +++ b/docs/CORRECAO_EXTRACAO_CPF_TEXTO_LIVRE.md @@ -0,0 +1,57 @@ +# Correção: extração de CPF/CNPJ/protocolo em texto livre + +## Problema + +Mensagem como: + +```text +consultar dados do cliente cpf 169.323.728-86 +``` + +não preenchia `BusinessContext.customer_key`, porque o `IdentityResolver` do framework resolve campos estruturados do payload/contexto, mas não extraía identificadores digitados livremente no texto. + +## Ajuste + +Adicionado: + +```text +app/identity_extraction.py +``` + +com extração defensiva de: + +- CPF +- CNPJ +- MSISDN com rótulo explícito +- protocolo/chamado/reclamação/ticket + +## Integração + +`app/main.py` agora enriquece o payload antes de chamar o `IdentityResolver`: + +```python +payload = enrich_payload_with_text_identity(req.payload or {}) +``` + +`app/agents/backoffice_agent.py` também usa a extração como fallback antes de chamar tools MCP, garantindo que `customer_key` e `protocol_id` sejam preenchidos quando vierem no texto. + +## Exemplo + +Entrada: + +```json +{ + "message": "consultar dados do cliente cpf 169.323.728-86" +} +``` + +Contexto extraído: + +```json +{ + "customer_key": "16932372886", + "cpf": "16932372886", + "document": "16932372886", + "document_type": "cpf" +} +``` diff --git a/docs/CORRECAO_IMPORTS_AGENT_FRAMEWORK_7.md b/docs/CORRECAO_IMPORTS_AGENT_FRAMEWORK_7.md new file mode 100644 index 0000000..2b78a41 --- /dev/null +++ b/docs/CORRECAO_IMPORTS_AGENT_FRAMEWORK_7.md @@ -0,0 +1,55 @@ +# Correção de compatibilidade com agent_framework(7) + +Esta versão remove referências a módulos que não existem no `agent_framework(7).zip`, principalmente: + +- `agent_framework.core` +- `agent_framework.components` +- `agent_framework.llm.factory` +- `agent_framework.observer.application_log` +- `agent_framework.observer.publishers` +- `agent_framework.observer.types` + +## LLM + +O provider de LLM do backoffice agora usa o ponto real do framework: + +```python +from agent_framework.llm.providers import create_llm +from agent_framework.config.settings import settings as fw_settings +``` + +Assim, `LLM_PROVIDER=oci_openai` passa a operar pelo provider oficial do framework: + +```python +agent_framework.llm.providers.OCICompatibleOpenAIProvider +``` + +## Exceptions + +Como o framework atual não possui `agent_framework.core.exceptions`, as exceções HTTP/de domínio do backoffice foram isoladas em: + +```text +src/core/exceptions.py +``` + +Isso mantém o tratamento de erro do backoffice sem inventar módulos inexistentes no framework. + +## Logging + +A dependência inexistente de `observer.application_log.StructuredLogger` foi removida. O backoffice usa um helper local mínimo apenas para compatibilidade do formatter. + +## Validação + +O script abaixo agora falha se algum import proibido reaparecer em `app/` ou `src/`: + +```bash +python tools/validate_parity.py +``` + +Validações executadas nesta versão: + +```text +OK: backoffice framework-native structural validation passed +OK: no forbidden agent_framework imports +COMPILE_OK +``` diff --git a/docs/CORRECAO_MCP_PARAMETROS_CPF_EXPLICITO.md b/docs/CORRECAO_MCP_PARAMETROS_CPF_EXPLICITO.md new file mode 100644 index 0000000..ea62383 --- /dev/null +++ b/docs/CORRECAO_MCP_PARAMETROS_CPF_EXPLICITO.md @@ -0,0 +1,8 @@ +# Correção MCP parâmetros + CPF explícito + +Esta versão corrige dois problemas observados no teste `consultar dados do cliente cpf 169.323.728-86`: + +1. O `BusinessContext.customer_key` permanecia com o `msisdn` padrão da sessão (`11999999999`) porque o `IdentityResolver` do framework preserva chaves já definidas. Agora CPF/CNPJ/protocolo explicitamente digitados na mensagem atual prevalecem sobre valores herdados da sessão. +2. O `MCPToolRouter` preserva `original_context` e `extra_args`; isso enviava muitos campos extras para o MCP Server, gerando `TypeError: unexpected keyword argument`. Agora o MCP Server filtra os argumentos usando o `input_schema` da tool antes de chamar o handler. + +Também foram adicionados aliases `cpf`, `cnpj`, `document` e `document_type` para tools de cliente/IMDB/Speech/ABRT/Portabilidade. diff --git a/docs/CORRECAO_PROTOCOL_ID_MCP_MAPPING.md b/docs/CORRECAO_PROTOCOL_ID_MCP_MAPPING.md new file mode 100644 index 0000000..4a3d3df --- /dev/null +++ b/docs/CORRECAO_PROTOCOL_ID_MCP_MAPPING.md @@ -0,0 +1,81 @@ +# Correção: protocol_id ausente nas tools MCP + +## Problema + +O backend retornava mensagens como: + +```text +[BackofficeAgent] Não foi possível consultar os dados do cliente. As ferramentas retornaram erro de protocolo ausente na requisição. +``` + +A causa principal era o arquivo `config/mcp_parameter_mapping.yaml` com o mapeamento invertido. + +O `MCPParameterMapper` do framework interpreta o mapa como: + +```yaml +canonical_key: tool_argument_name +``` + +Mas o pacote estava usando: + +```yaml +protocol_id: interaction_key +``` + +Como `protocol_id` não é uma chave canônica de `BusinessContext`, o mapper não enviava `protocol_id` para o MCP Server. + +## Correção + +O mapeamento foi corrigido para: + +```yaml +interaction_key: protocol_id +customer_key: customer_key +contract_key: contract_key +session_key: session_key +``` + +Também foram adicionadas fontes no `config/identity.yaml` para resolver `interaction_key` a partir de contratos legados do backoffice: + +- `protocol_id` +- `protocolo` +- `complaint_id` +- `complaintProtocol` +- `complaint.complaintProtocol` +- `complaint.protocol` +- `complaint.protocol_id` +- `complaint.id` +- `transactionId` +- `transaction_id` +- `ticket_id` + +Além disso, `app/agents/backoffice_agent.py` passou a enviar aliases defensivos (`protocol_id`, `interaction_key`, `customer_key`, `contract_key`, `session_key`) em `extra_args` para o `MCPToolRouter`, sem substituir o mapper do framework. + +## Resultado esperado + +Chamadas como: + +```json +{ + "channel": "web", + "payload": { + "message": "consultar cliente", + "protocol_id": "12345", + "customer_key": "11999999999" + } +} +``` + +ou payloads legados como: + +```json +{ + "payload": { + "transactionId": "12345", + "complaint": {"complaintProtocol": "12345"}, + "customer": {"msisdn": "11999999999"} + } +} +``` + +agora alimentam `BusinessContext.interaction_key` e chegam nas tools MCP como `protocol_id`. diff --git a/docs/GUARDRAILS_PARALLELOS_OBSERVER_IC.md b/docs/GUARDRAILS_PARALLELOS_OBSERVER_IC.md new file mode 100644 index 0000000..849fda1 --- /dev/null +++ b/docs/GUARDRAILS_PARALLELOS_OBSERVER_IC.md @@ -0,0 +1,127 @@ +# Guardrails paralelos fail-fast e Observer IC + +## O que foi implementado + +### 1. ParallelRailExecutor + +Arquivo principal: + +```text +agent_framework/src/agent_framework/guardrails/parallel_executor.py +``` + +Também foi criado um alias de compatibilidade: + +```text +agent_framework/src/agent_framework/guardrails/executor.py +``` + +Esse alias evita erro quando algum código antigo importar: + +```python +from agent_framework.guardrails.executor import ParallelRailExecutor +``` + +### 2. Execução paralela no GuardrailPipeline + +Arquivo alterado: + +```text +agent_framework/src/agent_framework/guardrails/pipeline.py +``` + +O pipeline continua retornando o contrato antigo: + +```python +(texto_final, list[RailDecision]) +``` + +mas internamente pode executar rails em paralelo com fail-fast. + +### 3. Execução paralela no OutputSupervisor + +Arquivo alterado: + +```text +agent_framework/src/agent_framework/guardrails/output_supervisor.py +``` + +O `OutputSupervisor` agora usa `ParallelRailExecutor` quando habilitado. + +### 4. Configuração + +Novas configurações: + +```env +ENABLE_PARALLEL_GUARDRAILS=true +GUARDRAILS_FAIL_FAST=true +``` + +Também foram adicionadas em: + +```text +agent_framework/src/agent_framework/config/settings.py +.env +.env.example +agent_template_backend/.env +agent_template_backend_day_zero/.env +``` + +### 5. Observer IC + +O `AgentObserver` já tinha `emit_ic()`. + +Foi complementada a API global compatível com FIRST/TIM: + +```python +from agent_framework.observer import ic, aic, noc, anoc, grl, agrl +``` + +Exemplos: + +```python +ic("AGENT_COMPLETED", data={"session_id": "..."}) +await aic("MCP_TOOL_CALLED", data={"tool_name": "consultar_fatura"}) +``` + +### 6. ICs automáticos no template backend + +O backend emite agora: + +```text +IC.AGENT_STARTED +IC.ROUTE_SELECTED +IC.MCP_TOOL_CALLED +IC.TOOL_CALLED +IC.AGENT_COMPLETED +``` + +Além dos eventos já existentes: + +```text +NOC.001 +NOC.005 +NOC.006 +GRL.001 ... GRL.009 +``` + +## Validações executadas + +Foram executadas validações locais com `PYTHONPATH=agent_framework/src`: + +```bash +python3 -m compileall -q agent_framework/src/agent_framework agent_template_backend/app agent_template_backend_day_zero/app +``` + +Smoke tests executados: + +```text +1. Import de ParallelRailExecutor via agent_framework.guardrails +2. Import de ParallelRailExecutor via agent_framework.guardrails.executor +3. Execução fail-fast: FastBlock cancela SlowAllow +4. GuardrailPipeline paralelo retorna RailDecision legado +5. OutputSupervisor paralelo retorna RailAction.BLOCK +6. API global observer.ic/noc/grl/aic/anoc/agrl +``` + +Observação: o import completo do `agent_template_backend.app.workflows.agent_graph` depende de `langgraph`, que não está instalado no sandbox de validação. O arquivo foi validado por `compileall`, e a dependência já consta em `agent_template_backend/requirements.txt`. diff --git a/docs/IMPLEMENTACAO_IC_NOC_GRL_SEM_REMOVER_LOGICA.md b/docs/IMPLEMENTACAO_IC_NOC_GRL_SEM_REMOVER_LOGICA.md new file mode 100644 index 0000000..edcd2c7 --- /dev/null +++ b/docs/IMPLEMENTACAO_IC_NOC_GRL_SEM_REMOVER_LOGICA.md @@ -0,0 +1,42 @@ +# Implementação IC/NOC/GRL preservando lógica existente + +Esta versão mantém a lógica original dos agentes do `agent_template_backend` e adiciona observabilidade corporativa. + +## IC adicionados nos agentes + +Cada agente agora emite eventos de negócio sem alterar a resposta final: + +- `IC.BILLING_AGENT_STARTED` / `IC.BILLING_AGENT_COMPLETED` +- `IC.ORDERS_AGENT_STARTED` / `IC.ORDERS_AGENT_COMPLETED` +- `IC.PRODUCT_AGENT_STARTED` / `IC.PRODUCT_AGENT_COMPLETED` +- `IC.SUPPORT_AGENT_STARTED` / `IC.SUPPORT_AGENT_COMPLETED` +- `IC._MCP_CONTEXT_COLLECTED` quando houver dados MCP +- `IC._RAG_CONTEXT_RETRIEVED` quando RAG estiver habilitado + +O mixin `AgentRuntimeMixin` também emite: + +- `IC.MCP_TOOL_CALLED` antes da chamada MCP +- `IC.TOOL_CALLED` após a chamada MCP + +## NOC + +O workflow já emite eventos operacionais principais: + +- `NOC.001` no início da execução +- `NOC.005` em exceção fatal +- `NOC.006` na persistência/finalização + +## GRL + +O backend agora também exemplifica emissão GRL no workflow: + +- `GRL.001` início do pipeline de guardrails +- `GRL.002` decisão allow +- `GRL.004` decisão block +- `GRL.009` decisão final agregada + +Quando `OutputSupervisor` está habilitado, ele continua sendo o principal mecanismo corporativo de supervisão de saída. + +## Garantia + +A lógica original dos agentes não foi substituída por stubs. As chamadas LLM, MCP, RAG, cache e os retornos originais foram preservados. diff --git a/docs/OPCAO_B_LLM_PROVIDER_TELEMETRY.md b/docs/OPCAO_B_LLM_PROVIDER_TELEMETRY.md new file mode 100644 index 0000000..eaab535 --- /dev/null +++ b/docs/OPCAO_B_LLM_PROVIDER_TELEMETRY.md @@ -0,0 +1,32 @@ +# Ajuste Opção B — Telemetria de LLM pelo framework + +Esta versão remove o uso de `generation(...)` dos nós de domínio do backoffice. + +## Decisão arquitetural + +- O backend backoffice não implementa nem simula `generation`. +- Os nós de domínio chamam `chat_llm_with_usage(...)`. +- `chat_llm_with_usage(...)` usa `agent_framework.llm.providers.create_llm(...)`. +- A telemetria de geração, tokens, custo, modelo e latência fica centralizada no provider do framework. + +## Arquivos ajustados + +- `src/agent/nodes/speech_enrichment_node.py` +- `src/agent/nodes/tim_complaint_analysis_node.py` +- `src/agent/nodes/treatment_decision_node.py` +- `src/agent/nodes/siebel_triplet_classification_node.py` +- `src/compat/framework_observer.py` +- `src/utils/ics_collector.py` + +## Removido + +- Import de `generation` nos nós de domínio. +- Context managers `with generation(...)`. +- Monkey patch de `agent_framework.observer.generation`. +- Provider legado `src/providers/langfuse_tracer.py`, que era redundante com o framework. + +## Mantido + +- Eventos AGA/NOC/IC/GRL continuam emitidos via `src.compat.framework_observer.event(...)`, que delega para `agent_framework.observer`. +- Eventos de erro LLM (`NOC.004`, `NOC.009`) continuam preservados nos nós. +- Tokens e uso do LLM continuam retornando em `LLMResponse` para ICs de negócio, mas a observabilidade de geração é responsabilidade do framework. diff --git a/docs/RELATORIO_FRAMEWORK_NATIVE_100.md b/docs/RELATORIO_FRAMEWORK_NATIVE_100.md new file mode 100644 index 0000000..a2ad1ef --- /dev/null +++ b/docs/RELATORIO_FRAMEWORK_NATIVE_100.md @@ -0,0 +1,110 @@ +# Relatório da refatoração framework-native + +## Resultado + +A versão foi refatorada para que o backoffice não execute mais grafos LangGraph legados diretamente. Os grafos originais do develop foram movidos para `legacy_reference_disabled/` e deixados apenas como referência/auditoria. + +## Arquitetura ativa + +O backend ativo usa: + +- `app/main.py` como camada de rotas/adapters. +- `app/workflows/backoffice_native_runtime.py` como runtime de workflows do framework. +- `src/agent/nodes/*` como nós de domínio. +- `src/components/clients/*` como services/clients de domínio. +- `src/agent/local_prompts/*` como prompts de domínio. +- `config/agents/backoffice_anatel/*` como políticas de guardrails, judges e prompt policy. + +## Fluxo checklist ativo + +```text +framework_input_guardrails +fetch_ticket +validation +bypass_rules +cache_check +imdb_enrichment +identity_verification +speech_enrichment +knowledge_base_enrichment +canceling_analysis +tim_complaint_analysis +operator_route +reclassification_analysis +treatment_decision +siebel_sr_opening +framework_output_supervisor +framework_output_guardrails +framework_judges +framework_supervisor_review +framework_persist +``` + +## Fluxo response emulator ativo + +```text +framework_input_guardrails +start_response_emulation +fetch_case +validate_actions +router +retrieve_templates +retrieve_history +generate_response +validate_response +persist_draft / approve_draft / close_case +framework_output_supervisor +framework_output_guardrails +framework_judges +framework_supervisor_review +framework_persist +``` + +## Diferença em relação à versão anterior + +Antes: + +```text +app/main.py registrava routers originais +rotas originais chamavam src.api.executors +executors chamavam src.agent.graphs +src.agent.graphs criava StateGraph diretamente +``` + +Agora: + +```text +app/main.py chama backoffice_runtime.execute_workflow +BackofficeNativeRuntime compila os workflows +os nós originais são apenas plugins de domínio +guardrails/judges/supervisor/checkpoint/telemetry entram no runtime +``` + +## Limites honestos + +A compilação está validada. A execução real depende das mesmas integrações externas do develop: IMDB, Siebel, Speech Analytics, TAIS KB, ABRT, Portabilidade, Autonomous DB, OCI Streaming e credenciais. + +## Ajuste aplicado: binding explícito dos guardrails do agente + +O runtime nativo agora resolve `config/agents.yaml`, localiza o profile `backoffice_anatel` e carrega explicitamente `guardrails_config_path`: + +```text +config/agents/backoffice_anatel/guardrails.yaml +``` + +Com isso, os guardrails continuam sendo **executados pelo framework** (`GuardrailPipeline` e `OutputSupervisor`), mas a política ativa vem do backend/domínio do agente. A metadata do workflow também passa a registrar: + +```text +agent_id + guardrails_profile + guardrails_config_path + active_input_rails + active_output_rails +``` + +Assim é possível validar em telemetry/debug que o backoffice não está usando apenas `config/guardrails.yaml` global, mas sim a configuração completa do agente: + +```text +INPUT_SIZE, MSK, PINJ, TOX, DLEX_IN, RAGSEC, VLOOP +REVPREC, DLEX_OUT, OOS, CMP, AOFERTA +``` diff --git a/docs/RELATORIO_PARIDADE_BACKOFFICE_100.md b/docs/RELATORIO_PARIDADE_BACKOFFICE_100.md new file mode 100644 index 0000000..d37e37e --- /dev/null +++ b/docs/RELATORIO_PARIDADE_BACKOFFICE_100.md @@ -0,0 +1,45 @@ +# Relatório de paridade — Backoffice convertido 100% + +## Resultado + +Esta versão deixa de ser apenas um agente genérico com ferramentas MCP e passa a hospedar a implementação original da branch `develop` dentro da aplicação do Agent Framework. + +## Paridade implementada + +| Item | Status | +|---|---| +| Grafo checklist original | Implementado | +| Grafo response emulator original | Implementado | +| Rotas REST originais | Implementadas | +| Schemas originais | Implementados | +| Clients TIM originais | Implementados | +| Prompts originais | Implementados | +| Nós de negócio originais | Implementados | +| Testes originais | Copiados em `tests_original_develop/` | +| Gateway novo do framework | Mantido | +| Guardrails do framework | Mantidos e reforçados | +| Judges do framework | Mantidos e reforçados | +| Supervisor do framework | Mantido | +| MCP Tool Router | Mantido | +| IC/NOC/AGA original | Preservado via `src/utils/ics_collector.py` e observer original | +| IC/NOC/GRL framework | Mantido via `AgentObserver` | + +## Decisão de arquitetura + +A lógica original de domínio foi mantida em `src/` para garantir paridade. O framework hospeda essa lógica e a envolve com as capacidades enterprise novas. + +Isso evita perder comportamento dos nós originais como validação ANATEL, IMDB, Speech Analytics, TAIS KB, cancelamento, reclassificação, tratamento, Siebel SR e Response Emulator. + +## Arquivos centrais alterados + +- `app/main.py`: registra workflows e rotas originais dentro do app do framework. +- `app/agents/backoffice_agent.py`: quando recebe ticket ANATEL completo pelo gateway, executa o workflow checklist original como workflow de domínio. +- `config/agents/backoffice_anatel/guardrails.yaml`: reforço dos rails de domínio. +- `config/agents/backoffice_anatel/judges.yaml`: judges específicos para protocolo, Siebel, rastreabilidade e emulator. +- `.env.example`: substitui `.env` com segredos reais. + +## Limites conhecidos + +- A execução real dos serviços TIM depende das variáveis de ambiente e conectividade corporativa. +- Em ambiente local sem VPN/credenciais, os clients originais podem falhar ou usar fallback/mock conforme o código original. +- Os testes copiados de `tests_original_develop/` ainda devem ser executados no ambiente com dependências completas do projeto original. diff --git a/docs/VALIDACAO_BACKEND_IC_NOC_GRL.md b/docs/VALIDACAO_BACKEND_IC_NOC_GRL.md new file mode 100644 index 0000000..a9e4458 --- /dev/null +++ b/docs/VALIDACAO_BACKEND_IC_NOC_GRL.md @@ -0,0 +1,62 @@ +# Validação da versão com IC/NOC/GRL + +Validações executadas nesta geração: + +1. `python -m compileall -q agent_template_backend/app` + - Resultado: OK. + +2. Smoke test dos agentes com LLM fake e Observer fake: + - `BillingAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim. + - `OrdersAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim. + - `ProductAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim. + - `SupportAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim. + +3. Verificação de regressão: + - Nenhum agente retorna `Template Enterprise ativo`. + - A lógica LLM/MCP/RAG/cache existente foi preservada. + +## Eventos adicionados + +### IC + +Nos agentes: + +- `IC.BILLING_AGENT_STARTED` +- `IC.BILLING_MCP_CONTEXT_COLLECTED` +- `IC.BILLING_RAG_CONTEXT_RETRIEVED` +- `IC.BILLING_AGENT_COMPLETED` +- `IC.ORDERS_AGENT_STARTED` +- `IC.ORDERS_MCP_CONTEXT_COLLECTED` +- `IC.ORDERS_RAG_CONTEXT_RETRIEVED` +- `IC.ORDERS_AGENT_COMPLETED` +- `IC.PRODUCT_AGENT_STARTED` +- `IC.PRODUCT_MCP_CONTEXT_COLLECTED` +- `IC.PRODUCT_RAG_CONTEXT_RETRIEVED` +- `IC.PRODUCT_AGENT_COMPLETED` +- `IC.SUPPORT_AGENT_STARTED` +- `IC.SUPPORT_MCP_CONTEXT_COLLECTED` +- `IC.SUPPORT_RAG_CONTEXT_RETRIEVED` +- `IC.SUPPORT_AGENT_COMPLETED` + +No runtime MCP: + +- `IC.MCP_TOOL_CALLED` +- `IC.TOOL_CALLED` + +### NOC + +Já integrados no workflow: + +- `NOC.001` início da execução +- `NOC.005` erro fatal +- `NOC.006` finalização/persistência + +### GRL + +No workflow de guardrails: + +- `GRL.001` início da avaliação +- `GRL.002` allow +- `GRL.004` block +- `GRL.009` decisão final + diff --git a/docs/VALIDACAO_TEMPLATE_ENTERPRISE.txt b/docs/VALIDACAO_TEMPLATE_ENTERPRISE.txt new file mode 100644 index 0000000..fac4bf4 --- /dev/null +++ b/docs/VALIDACAO_TEMPLATE_ENTERPRISE.txt @@ -0,0 +1,3 @@ +compileall app: OK +Arquivos de exemplos IC/NOC/GRL adicionados. +Agentes preservam implementação original comentada. diff --git a/docs_original_develop/README.md b/docs_original_develop/README.md new file mode 100644 index 0000000..9d91b23 --- /dev/null +++ b/docs_original_develop/README.md @@ -0,0 +1,3 @@ +# Introduction + Documentação relevante -- (txt) + \ No newline at end of file diff --git a/docs_original_develop/api-clients.md b/docs_original_develop/api-clients.md new file mode 100644 index 0000000..614ad36 --- /dev/null +++ b/docs_original_develop/api-clients.md @@ -0,0 +1,137 @@ +# Padrão de implementação para clients de API externa + +Este documento define o padrão obrigatório para qualquer client novo +que faça chamadas a APIs externas (HTTP ou outras). Ele garante que +todas as requisições apareçam no Langfuse com endpoint, status code +e response body, **sem que o dev precise escrever código de tracing +manual**. + +## 1. Use sempre `traced_async_client` / `traced_sync_client` + +Nunca instancie `httpx.AsyncClient` (ou `httpx.Client`) diretamente +em um client de API. Use os factories de [`src/utils/http.py`](../src/utils/http.py): + +```python +from src.utils.http import traced_async_client + +async with traced_async_client(timeout=self.timeout) as client: + response = await client.get(url, headers=headers) + # ... +``` + +O factory devolve um `httpx.AsyncClient` que automaticamente: + +- registra o **endpoint real** (URL completa, com path e query string) + da request realizada — não depende de `self.base_url` ou `self.url`; +- registra o **status code** retornado; +- registra o **response body** (JSON ou texto truncado em 2000 chars); +- registra metadata também em **erros de rede** (timeout, connect error) + com `status_code=None` e o erro como body. + +Argumentos comuns aceitos: `timeout`, `verify_ssl` (default +`settings.VERIFY_SSL`), `headers`, `base_url`, `response_sanitizer`, +e qualquer outro kwarg de `httpx.AsyncClient`. + +## 2. Decore os métodos públicos com `@trace_tool` + +Aplique `@trace_tool` em todo método que faz chamada externa, incluindo +helpers privados de autenticação (`_get_token`, `_refresh_xxx`). Cada +método decorado vira um span filho próprio no Langfuse, com nome +`NomeDaClasse.nome_do_metodo` — então uma classe com múltiplas APIs +(ex: `SpeechAnalyticsClient._get_token` vs `.get_prediction` vs +`.get_history`) não perde a granularidade. + +```python +from src.utils.observer import trace_tool + +class MyClient: + @trace_tool + async def get_something(self, ...) -> ...: + ... +``` + +## 3. Convenções de nomenclatura + +- **Métodos** com verbo + recurso: `get_history`, `post_prediction`, + `open_service_request`. O nome do método aparece no nome do span. +- **`__init__`** não precisa expor `base_url` para fins de tracing — + o endpoint real vem da request. Mantenha `self.base_url` apenas se + o client realmente precisa dela para construir URLs. + +## 4. Sanitização de payloads sensíveis + +Quando a resposta contém credenciais, tokens ou dados sensíveis que +não devem ir para o Langfuse, passe um `response_sanitizer` ao factory. +A função recebe o body já parseado e devolve a versão filtrada: + +```python +def _sanitize_token_response(body): + if not isinstance(body, dict): + return body + return { + "expires_in": body.get("expires_in"), + "status": body.get("status"), + } + +async with traced_async_client( + timeout=settings.SPEECH_TIMEOUT, + response_sanitizer=_sanitize_token_response, +) as client: + response = await client.post(url, data=...) +``` + +Exemplo real: [`SpeechAnalyticsClient._get_token`](../src/components/clients/speech_analytics_client.py). + +## 5. Clients que não usam HTTP + +Para clients que não falam HTTP (ex: [`TaisKbClient`](../src/components/clients/tais_kb_client.py), +que usa OCI SDK + oracledb), basta decorar o método público com +`@trace_tool`. O decorator cai no fallback de logar o retorno da função +como output do span. Não tente sintetizar um endpoint via +`set_tool_call_metadata` — apenas adiciona ruído. + +## 6. Exemplo mínimo + +```python +import httpx +import logging +from src.utils.observer import trace_tool +from src.utils.http import traced_async_client +from src.core.config import settings + +logger = logging.getLogger(__name__) + + +class FooClient: + def __init__(self) -> None: + self.base_url = settings.FOO_API_BASE_URL + self.token = settings.FOO_API_TOKEN + self.timeout = settings.FOO_API_TIMEOUT + + @trace_tool + async def get_foo(self, foo_id: str) -> dict: + url = f"{self.base_url}/foo/{foo_id}" + headers = {"Authorization": f"Bearer {self.token}"} + try: + async with traced_async_client(timeout=self.timeout) as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + logger.error(f"Foo API HTTP error {exc.response.status_code}") + raise +``` + +Nada de `set_tool_call_metadata` espalhado pelo método. O span no +Langfuse já vai conter `endpoint`, `status_code` e `response` +automaticamente. + +## Checklist de revisão + +Ao revisar um PR de novo client, valide: + +- [ ] Usa `traced_async_client` / `traced_sync_client` (não `httpx.AsyncClient` direto). +- [ ] Métodos públicos têm `@trace_tool`. +- [ ] Não há chamadas manuais a `set_tool_call_metadata`. +- [ ] Respostas com dados sensíveis passam por `response_sanitizer`. +- [ ] Nomes de método são `verbo_recurso` (legíveis como nome de span). diff --git a/docs_original_develop/ics.md b/docs_original_develop/ics.md new file mode 100644 index 0000000..64bec5d --- /dev/null +++ b/docs_original_develop/ics.md @@ -0,0 +1,119 @@ +# Itens de Controle (IC's) - TIM + +## Geral + +1. Jornadas / Negócio — Exemplo: Prefixo AGC + + Usado para marcar informações relacionadas ao negócio ou dados do cliente no agente de contas. + + Exemplos de uso: + + Tipo de cliente + + Informações de entrada + + Situação de dívida + + Realização de promessa de pagamento + +2. Infraestrutura / Motor — Prefixo LLM + + Usado para eventos técnicos e de execução. + + Exemplos de uso: + + - Chamadas e respostas de APIs (sucesso ou falha) + + - Erros de execução + + - Acionamento de outros agentes + + - Finalização anômala do agente + +3. Contato com o Cliente — Prefixo CTT + + Usado para interações relacionadas ao contato direto com o cliente. + + Exemplos de uso: + + - Dados provenientes do gerenciador de campanha + + - Tipo de acionamento + +4. GuardRails — Prefixo GRL + + Usado quando existe intervenção ou bloqueio motivado por guardrails. + + Exemplo: + + - Acionamento de regra de segurança ou restrição operacional + +### + +--- + +# IC'S Específicos Agente Anatel (Feature 1366645) + + + + +IC Descrição + +**AGA.001** Entrada do Agente: Chamados Entrantes para Agente + +**AGA.002** Resultado da validação do chamado = Cancelar + +**AGA.003** Resultado da validação do chamado = Reclassificação + +**AGA.004** Resultado da validação do chamado = Reencaminhamento + +**AGA.005** Resultado da validação do chamado = Seguir via (LLM) + +**AGA.006** Resultado da ação no chamado: Foi aberto no Siebel = sim + +**AGA.007** Resultado da ação no chamado: Foi aberto no Siebel = não + +**AGA.008** Resultado da ação no chamado: Teve Erro = sim + +**AGA.009** Resultado da ação no chamado: Teve Erro = não + +**AGA.010** Resultado consulta Speech + +**AGA.011** Resultado IMDB + +**AGA.012** Resultado Base de Conhecimento (TAIS - procedimentos) + +**AGA.013** Registro resultado da busca de Agente Especializado (LLM) + +**AGA.014** Houve acionamento do Agente Especializado (LLM) = sim + +**AGA.015** Houve acionamento do Agente Especializado (LLM) = não + +**AGA.016** Houve Criação de Chamado Siebel para Tratamento (quando enviado para SMART HUMAN ou +Agente Especializado) = sim + +**AGA.017** Houve Criação de Chamado Siebel para Tratamento (quando enviado para SMART HUMAN ou +Agente Especializado) = não + +**AGA.018** Houve retorno do resultado do Chamado (resultado do Agente ou Resultado do Smart +Human) + + + + + +**AGA.019** Registro da busca dos detalhes do chamado no Siebel para Resposta + +**AGA.020** Registro da busca do template na Base de Conhecimento (TAIS) + +**AGA.021** Registro da quantidade de template retornado na base de conhecimento (TAIS) + +**AGA.022** Houve registro do envio de dados para resposta no Emulador = sim + +**AGA.023** Houve registro do envio de dados para resposta no Emulador = não + +**AGA.024** Registro da resposta (LLM) no Emulador + +**AGA.025** Registro da ação do Agente Supervisor no Emulador + +**AGA.026** Registro do retorno da Resposta Final no Emulador \ No newline at end of file diff --git a/docs_original_develop/logging.md b/docs_original_develop/logging.md new file mode 100644 index 0000000..d90fc60 --- /dev/null +++ b/docs_original_develop/logging.md @@ -0,0 +1,304 @@ +# Logs de Aplicação + +Este guia descreve como o logging está organizado, o que cada peça faz, e +como adicionar logs em novas features de forma consistente com a política +de monitoramento (doc interno 1330576 — *Monitoramento de Aplicação - OCI +Logging*). + +--- + +## Visão geral + +- **Destino dos logs:** stdout (logs `< ERROR`) e stderr (logs `>= ERROR`). **A + aplicação não envia logs diretamente para fora.** +- **Formato em produção:** JSON (`LOG_FORMAT=json`). +- **Enriquecimento automático:** todo log recebe `service`, `correlation`, + `source_channel`, `conversation_start_time_ts`, `requesting_agent` e + `user_id` automaticamente via *contextvars* + +Arquivo central: [src/core/logging.py](../src/core/logging.py). + +--- + +## Estrutura do JSON emitido + +```json +{ + "timestamp": "2026-05-12T03:16:12.726Z", + "level": "INFO", + "logger": "src.agent.nodes.validation_node", + "message": "process started", + "source_channel": "ura", + "conversation_start_time_ts": "2026-03-20T14:32:10Z", + "requesting_agent": "agent_bo", + "service": { + "name": "Agent Microservice", + "version": "0.4.0", + "component": "validation_node", + "agent_type": "backoffice" + }, + "correlation": { + "trace_id": "...", + "request_id": "...", + "span_id": "...", + "session_id": "..." + }, + "user_id": "...", + "operation": { + "name": "process", + "status": "started", + "execution_time": 0.001, + "...": "campos custom adicionados via add_field" + } +} +``` + +- `service.component` é derivado **automaticamente** do nome do logger + (`record.name.rsplit(".", 1)[-1]`). Para sobrescrever, passe + `extra={"component": "outro_nome"}` ou use o helper (`log_operation`). +- O bloco `operation` só aparece quando você está dentro de um + `log_operation(...)` ou passa `extra={"operation": {...}}` manualmente. +- Em falhas, é adicionado o campo `exception` com o stacktrace. + +--- + +## Quem popula os contextvars + +| Contextvar | Quem seta | Onde | +|---|---|---| +| `request_id` | `LoggingMiddleware` (HTTP) ou `set_ticket_log_context` (consumer OCI) | [middleware/logging.py](../src/api/middleware/logging.py), [dependencies/logging_context.py](../src/api/dependencies/logging_context.py) | +| `session_id` | `set_ticket_log_context` | idem | +| `user_id` | `set_ticket_log_context` (Origin.submittedBy.userId) | idem | +| `trace_id` | `set_ticket_log_context` (hoje = transaction_id; Fase 2 trocará para trace OTEL) | idem | +| `source_channel` | `set_ticket_log_context` (Origin.sourceSystem) | idem | +| `conversation_start_time_ts` | `set_ticket_log_context` (Complaint.openedAt) | idem | +| `requesting_agent` | `set_ticket_log_context` (Origin.submittedBy.name) | idem | +| `span_id` | (reservado — não populado hoje) | — | + +Limpeza: `clear_context()` é chamado no `finally` do middleware HTTP e do +consumer OCI para evitar vazamento entre requisições. + +--- + +## API pública + +```python +from src.core.logging import ( + get_logger, + log_operation, # context manager — preferido + set_request_id, + set_session_id, + set_user_id, + set_trace_id, + set_span_id, + set_source_channel, + set_conversation_start_ts, + set_requesting_agent, + clear_context, + MetricsLogger, # legado — prefira log_operation +) +``` + +### `get_logger(name)` + +Wrapper sobre `logging.getLogger(name)`. Garante que o root logger está +configurado. Convenção: `logger = get_logger(__name__)` no topo do módulo. + +### `log_operation(name, *, component=None, logger=None, **fields)` + +Context manager (funciona com `with` e `async with`) que emite: +- ` started` no entry. +- ` completed` no exit feliz, com `execution_time` em segundos. +- ` failed: ` no exit por exceção, com `exc_info` e + `error_type` — a exceção é re-raised normalmente. + +`component` quando omitido é derivado do nome do logger. + +Métodos do objeto retornado: +- `op.add_field(key, value)` — anexa um campo ao bloco `operation` que + será emitido nos logs de success/failed (e em `op.event(...)`). +- `op.event(message, level=INFO, **fields)` — emite um log + *intermediário* dentro da operação (status `in_progress`), sem fechá-la. + +--- + +## Guia de uso — quando e como logar + +### Regra mental + +1. **Operação clara com início e fim** (rota, node, chamada externa, job) + → use `log_operation`. +2. **Evento pontual dentro de uma operação** (cache hit/miss, retry, + decisão tomada) → `op.event(...)` ou `logger.info(...)` direto. +3. **Diagnóstico técnico fino** → `logger.debug(...)`. +4. **Algo inesperado mas não fatal** → `logger.warning(...)`. +5. **Falha em bloco `except`** → `logger.error(msg, exc_info=True)` ou + deixe `log_operation` propagar (já loga com `exc_info`). + +### NUNCA + +- Não passe `session_id`/`request_id`/`user_id` em `extra=` — eles já são + injetados pelo `ContextFilter`. Adicionar manualmente só duplica. +- Não use `logger.error(str(e))` sem `exc_info=True` em bloco `except` — + perde o stacktrace. +- Não use `logger.error(..., exc_info=True)` **fora** de bloco `except` + — sem exceção ativa o campo `exception` vira `NoneType: None`. +- Não logue payloads com PII (CPF, telefone, token, senha). Mascarar / + anonimizar antes (política §2 do doc). +- Não escreva em arquivo, não chame APIs externas para enviar logs — + apenas stdout/stderr. Transporte é da infra. + +### NÃO crie wrappers próprios + +Se aparecer a tentação de criar um decorator/helper local para "padronizar +log de início e fim", **pare**. Use `log_operation`. Múltiplos padrões na +base é exatamente o que essa refatoração eliminou. + +--- + +## Receitas + +### 1. Rota HTTP / handler + +O `LoggingMiddleware` já envolve toda requisição em +`log_operation("http_request")`. Dentro da rota, **não precisa logar +"recebi requisição" / "finalizei requisição"**. Logue apenas eventos de +negócio relevantes. + +### 2. Node do agente + +**Não precisa fazer nada.** O decorator `@trace_node` em +[src/utils/observer.py](../src/utils/observer.py) já envolve cada node +com `log_operation(func_name, component=)` e injeta +`message_count` e `iteration_count` automaticamente. + +Se quiser enriquecer com campos próprios do node, use `logger.info` +dentro do node — o bloco `service` e a contextualização vêm de graça: + +```python +logger = get_logger(__name__) + +@trace_node +async def process(state: AgentState) -> AgentState: + # start/end são logados automaticamente pelo decorator + logger.info( + "Customer enriched from IMDB", + extra={"payload": {"msisdn_masked": mask(msisdn), "cached": False}}, + ) + return state +``` + +### 3. Cliente HTTP externo + +Métodos decorados com `@trace_api` ou `@trace_tool` já estão envolvidos +em `log_operation`. Para logar retries dentro do loop, **emita um log +estruturado com o bloco `operation` em status `in_progress`**: + +```python +for attempt in range(max_retries + 1): + try: + return await call_api(...) + except TransientError as exc: + if attempt == max_retries: + raise + wait = backoff(attempt) + logger.warning( + "Cliente: tentativa falhou, repetindo", + extra={ + "operation": { + "name": "get_invoice_buffer", + "status": "in_progress", + "attempt": attempt + 1, + "max_retries": max_retries, + "backoff_seconds": round(wait, 3), + "last_error": str(exc), + }, + "component": "pdf_invoice_recover", + }, + ) + await asyncio.sleep(wait) +``` + +Para uma operação de API que **não** use `@trace_api`/`@trace_tool` +(caso raro), envolva manualmente: + +```python +with log_operation( + "get_invoice_buffer", + component="pdf_invoice_recover", + logger=logger, + end_point_name="get_pdf", + operacao_realizada="consulta", + id_consulta=invoice_id, +) as op: + op.add_field("attempt", attempt) + response = http_call(...) + op.add_field("status_code", response.status_code) +``` + +### 4. Job de background / consumer + +Igual à rota: envolva o trabalho da unidade (uma mensagem consumida, um +job rodado) em `log_operation`. Veja [src/api/main.py](../src/api/main.py) +`process_incoming_ticket` como referência. + +### 5. Cache / acesso a memória + +Toda operação de I/O que possa falhar merece `log_operation`. Padrão usado +no projeto: + +```python +try: + async with log_operation("save_state_to_memory", component="memory", logger=logger): + await save_state_to_memory(state) +except Exception: + # log_operation já emitiu failed com exc_info — silencie aqui + # se o caminho feliz não depende do cache. + pass +``` + +--- + +## Níveis de log — política + +| Nível | Quando usar | +|---|---| +| `DEBUG` | Diagnóstico fino. Em produção fica desligado (LOG_LEVEL=INFO). | +| `INFO` | Eventos de negócio e fluxo normal — início/fim de operação, decisão tomada, recurso criado. | +| `WARNING` | Algo inesperado mas recuperável — fallback acionado, retry transitório, dado opcional ausente. | +| `ERROR` | Falha de operação. Sempre **dentro de `except`** com `exc_info=True`. | +| `CRITICAL` | Falha total / risco alto / corrupção de estado. | + +Configuração por ambiente: +- Produção: `LOG_LEVEL=INFO`, `LOG_FORMAT=json`. +- Homologação/Dev: `LOG_LEVEL=DEBUG`, `LOG_FORMAT=text` (mais legível + localmente; o split stdout/stderr funciona em ambos os formatos). +- Loggers ruidosos (`httpx`, `urllib3`, `pymongo`, etc.) são silenciados + para `WARNING` por padrão — ajuste via `LOG_NOISY_LEVEL`. + +--- + +## Checklist para PR adicionando novos logs + +- [ ] Usou `log_operation` para operações com início/fim claros? +- [ ] Em bloco `except`, `logger.error(..., exc_info=True)`? +- [ ] Não passou `session_id`/`request_id`/`user_id` redundantes em `extra`? +- [ ] Não logou PII (CPF, telefone, token, payload bruto do cliente)? +- [ ] Quando adicionou campos custom, encaixou no bloco `operation` ou em + `payload` (não criou um nível superior novo)? +- [ ] Se for um cliente HTTP com retry, registrou `attempt` e + `max_retries` nos logs de retentativa? +- [ ] Verificou localmente com `LOG_FORMAT=json` que o JSON contém os + blocos esperados? + +--- + +## Referências + +- Política interna: doc 1330576 — *Monitoramento de Aplicação - OCI Logging*. +- Implementação base: [src/core/logging.py](../src/core/logging.py). +- Decorators que aplicam `log_operation` automaticamente: + [src/utils/observer.py](../src/utils/observer.py) (`@trace_node`, + `@trace_api`, `@trace_tool`). +- Propagação do contexto da requisição: + [src/api/dependencies/logging_context.py](../src/api/dependencies/logging_context.py). diff --git a/legacy_reference_disabled/.DS_Store b/legacy_reference_disabled/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6535b15d792ddf3275bfe0e12580d67bc41f465c GIT binary patch literal 6148 zcmeHKOHRWu5Peg+sKh2q7A$;)-XK)r1U&#yQk6<6i7LT{jc>;ZI0U!gjc52&L;|ru z2+c_2=h*Z7c~Wc-09o9%bD#n+qbrJ5MogPWyCxnyB}y4%i(6cw!v)%rf&OBXuDwE! zJ+|1;-`Bs!J=WYykD=Slm)HGnC>xlZDtyV=^-Z(t`W0hUyr+lf$FuM8+8^Gx`=%v!RZn?S)Q^hd>b|icsJe6nFtF`a1Lg literal 0 HcmV?d00001 diff --git a/legacy_reference_disabled/original_develop/.DS_Store b/legacy_reference_disabled/original_develop/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..cb97cc2d91c80700cd4f65aff02ded687be146ac GIT binary patch literal 6148 zcmeHK%TB{E5Zo<2RDe*qar7_r&LLFc#IYARL0@W-S`xKX;+Wsyi})7)f?4khHBDL~ z4uH_Ev>wOyB%YCM$3(>a&7?=vC87>euzif8CZu1o5-n>v1#*1HkY+TcCwiv)%4lzJ z2i$?b=76l-F0E-rW31Wj^*ck`kaC*SdNJz_#@RC0$=}{I;ViQ>9WAmE#$xj*?k?Zo zuA1>C-{R%-?K#G&o^R>O=@I&-^nx7I20K&+eY?tDF~bI|J=z*EYt=Pi*62H{=vzUR zXq)aRnUbP?x)eaDriCSSX?g!Ab=xmFv(^N z!-*3`FWmumpzOerK6d5&zutfUFO&Sq9dHN!l>^*P2WcOY>};)wle0D;ACM9 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() diff --git a/legacy_reference_disabled/original_develop/src_agent_graphs/llm_testing_graph.py b/legacy_reference_disabled/original_develop/src_agent_graphs/llm_testing_graph.py new file mode 100644 index 0000000..12b683f --- /dev/null +++ b/legacy_reference_disabled/original_develop/src_agent_graphs/llm_testing_graph.py @@ -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() diff --git a/legacy_reference_disabled/original_develop/src_agent_graphs/main_graph.py b/legacy_reference_disabled/original_develop/src_agent_graphs/main_graph.py new file mode 100644 index 0000000..2f552dd --- /dev/null +++ b/legacy_reference_disabled/original_develop/src_agent_graphs/main_graph.py @@ -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() diff --git a/legacy_reference_disabled/original_develop/src_api_executors/__init__.py b/legacy_reference_disabled/original_develop/src_api_executors/__init__.py new file mode 100644 index 0000000..6533032 --- /dev/null +++ b/legacy_reference_disabled/original_develop/src_api_executors/__init__.py @@ -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", +] diff --git a/legacy_reference_disabled/original_develop/src_api_executors/checklist.py b/legacy_reference_disabled/original_develop/src_api_executors/checklist.py new file mode 100644 index 0000000..49926b1 --- /dev/null +++ b/legacy_reference_disabled/original_develop/src_api_executors/checklist.py @@ -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() diff --git a/legacy_reference_disabled/original_develop/src_api_executors/response_emulator.py b/legacy_reference_disabled/original_develop/src_api_executors/response_emulator.py new file mode 100644 index 0000000..24463b4 --- /dev/null +++ b/legacy_reference_disabled/original_develop/src_api_executors/response_emulator.py @@ -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() diff --git a/legacy_reference_disabled/original_develop/src_api_main.py b/legacy_reference_disabled/original_develop/src_api_main.py new file mode 100644 index 0000000..ecc88fa --- /dev/null +++ b/legacy_reference_disabled/original_develop/src_api_main.py @@ -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() diff --git a/legacy_reference_disabled/original_develop/src_api_routes/__init__.py b/legacy_reference_disabled/original_develop/src_api_routes/__init__.py new file mode 100644 index 0000000..679d0a0 --- /dev/null +++ b/legacy_reference_disabled/original_develop/src_api_routes/__init__.py @@ -0,0 +1,10 @@ +""" +API route modules. +""" + +from src.api.routes import agent, health + +__all__ = [ + "agent", + "health", +] diff --git a/legacy_reference_disabled/original_develop/src_api_routes/agent.py b/legacy_reference_disabled/original_develop/src_api_routes/agent.py new file mode 100644 index 0000000..0fbe190 --- /dev/null +++ b/legacy_reference_disabled/original_develop/src_api_routes/agent.py @@ -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)}" + ) diff --git a/legacy_reference_disabled/original_develop/src_api_routes/emulator.py b/legacy_reference_disabled/original_develop/src_api_routes/emulator.py new file mode 100644 index 0000000..9964ab9 --- /dev/null +++ b/legacy_reference_disabled/original_develop/src_api_routes/emulator.py @@ -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, + ) diff --git a/legacy_reference_disabled/original_develop/src_api_routes/emulator_rag.py b/legacy_reference_disabled/original_develop/src_api_routes/emulator_rag.py new file mode 100644 index 0000000..13f5a7e --- /dev/null +++ b/legacy_reference_disabled/original_develop/src_api_routes/emulator_rag.py @@ -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 diff --git a/legacy_reference_disabled/original_develop/src_api_routes/health.py b/legacy_reference_disabled/original_develop/src_api_routes/health.py new file mode 100644 index 0000000..10f584c --- /dev/null +++ b/legacy_reference_disabled/original_develop/src_api_routes/health.py @@ -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 + ) diff --git a/mcp_servers/.DS_Store b/mcp_servers/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4b912d1ad735340688494300b2fc74941fe9c3a4 GIT binary patch literal 10244 zcmeHMdu$X%7@u!jVAryA%0qf>k%d!iw1g{+2LDj}$3KkU%Fa*U>(Yz6St;eZcDDMiXZtO)lA-gvUB}lg$1HLK z%P_IYo&n1-+)9^Qatx2#CPjsUDMK5_#@5B!v{kXShuXBU_E>v+o3?sY$Du=tydb(_ z!>-H$JMXyni$OxLP!M&}pdXmti0$F_WX{bOxEE-?Z<^L#xm(~>ldj@*I>1X-E~}fZ z&QS-426yjK_ol6!>m;p%W`@>#2wM*pcNWbu!Zna{?NZ+HGPEJ5+qsgh`(~$Yv&P<# zKAJK|{i2iE)zhxHtDwg4o4C-Fa zbPUTG9w=L$m9@>3qvy>A*rF&C^BU&TNK>RmrcpUv%38OSEdN%nRToE=DD+i%kAWYv z-8Gm;L$qHUxk{FIGktf_#9XM=*2pyq)#a3jm9h5$ zX$#d_b)8Imm4UJtp-^sAH^~jqrlfJ+^Ee()s5>LwGW|f_%lh7?d;X4s>HP6kH^}lJ ztLZchRMMWUz1y}&B2%F-b4LZ^pPrOu-u@q zyaI2)oA4GKgLmKrd;}lEC-5npgwyaXoPnPv1^SH_20Hvay+F^qiA1nf<0e0z-uV6T z&gptEF403_*aI=1T!qg9ps%tM$$1VRKt1VRKt1VRKZ0f8D}tFin4 z%~SvWe+fN=rXd0$0{?adpf-_Abm3P{^plPt(OMxMbKXKP+8dChnQrSv}R|Sq!s-SKf+(yt3M#@4>$p3 zJC}KHc=P7XIGBMGRh>?cp?*hml^Z;#n?SIO|X9XypY2cfI|!LCB|}MIOy1A?ewO8QWfFO+nls% zc^#T!^M7fK6rTO@+KKJNpgfZbHGwW23fRotqE3W-zS?5>^Q22|8b4;|aj zZ{EClGyC>?Kbyf|ErQWE)G@bFX+ zYtNZ+5B7+x=o#n6yx7a57Sw@6ZZ8tO{j_5V`(_xy_n1rs%JR#arkk><>Y5?Y7Bi$R zSnP`Iz`8=^kUB$pD2P zwRy@u@^mO;iwQ|k@$H(fJnpF|R2Y%!Cl3iDfilFRJ!X(VPg+z&U@f2FKCTtSieXCA zvY|*hoUu4;m{`qPzOn>-#~Ml|je}srAm{VEWk%*TGm?r-h$r|{Sf5jprV){6;DMwx zmgkjAIzBJyNi{NEaj}%twTlWi)s()a6PZtDB}2imU~Jdj5=WA@pNyXDg5{GWRZ~q# zvf6g?Wlwq8Pi`6p@+m5{@LvXj6kB=MLCmGTu9;u zBJ#Prj29uqF>2I|{PoMn zt{f}+g@Ruw`XdE@WW(RLCT;jf))Nn=?oHi4Tbxh|6G}d<7bmiXiEL?i+jZso$N8rH zOQRKIJ%v!unkQd>IPW`*o8jnWB`#s&gP$Gsi!T9Q<>dgtrz*R*VqKx8Dxir&x~(1^ z1eWBasu8!7^z%Z{BmrMFDqy1rXc}ouf{7DXR*jtjU1tL-PF`Kr5ScRk0B%fz7TEz> zV-sw=u}Tr9v}a8-`-#OlrN9Y?COZIJbC4S0oRopv z$Yy3nF)c3RCw4KJ&$y)y|s-ZyYRkj1)RX z)>9AW@6F%We+r(N>uH%g6rt_`dVd^7mei|Qn*7(55xXPX3=-!~1_#k!dvx@%)m z@Bg6$9%4X;saop-u!cil~H z9E#=phV$W(^&?*o{zG)*LvR@=aU@^ZPML&>?Hp3np&R3{V&<^6SVx-KDR5FZ59F-C zIeuYaVh8@SZ3@u*p2hDHi&0|4QN;s9KoX`c-L_ssS}hIwIB2zMC23wycmMbjk-lXPvrJ={yU!$7QN$<9w|Qj$$*ET?m(Vo1_0WS2lY2Vv5X z*o2fJV>gh`(G!~X3_kI3o+D2}oG1L05N8X~7HY_~(mC)0Tnm#@XmRF37W#KgDj+6V z&cRm2bIvjElLb;X-1K+UdA9fPFvy@JAfpw??+iszzo3ra(Cd#-;8)c3lxwBJe;^?C G=Dz^Y`I_zk literal 0 HcmV?d00001 diff --git a/mcp_servers/backoffice_mcp_server/__pycache__/main.cpython-313.pyc b/mcp_servers/backoffice_mcp_server/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b253c11ca53eed9f0b5962ef234d701839339af6 GIT binary patch literal 7589 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 Bh#&v} literal 0 HcmV?d00001 diff --git a/mcp_servers/backoffice_mcp_server/__pycache__/models.cpython-313.pyc b/mcp_servers/backoffice_mcp_server/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff077b308da30e64927369ae678072898ac1f30f GIT binary patch literal 1955 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 literal 0 HcmV?d00001 diff --git a/mcp_servers/backoffice_mcp_server/__pycache__/store.cpython-313.pyc b/mcp_servers/backoffice_mcp_server/__pycache__/store.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b19bcd99190f1b6127032b8fcb9db50e99147c4c GIT binary patch literal 19084 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 F{{s+5qXGZ` literal 0 HcmV?d00001 diff --git a/mcp_servers/backoffice_mcp_server/clients.py b/mcp_servers/backoffice_mcp_server/clients.py new file mode 100644 index 0000000..8c77153 --- /dev/null +++ b/mcp_servers/backoffice_mcp_server/clients.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Any, Dict + +import httpx + +from .settings import BackofficeMCPSettings + + +class BackofficeRESTClient: + def __init__(self, settings: BackofficeMCPSettings): + self.settings = settings + self.base_url = settings.rest_base_url.rstrip("/") + + def _headers(self) -> Dict[str, str]: + if not self.settings.rest_api_key: + return {} + value = self.settings.rest_api_key + if self.settings.rest_auth_header.lower() == "authorization" and not value.lower().startswith("bearer "): + value = f"Bearer {value}" + return {self.settings.rest_auth_header: value} + + def get_json(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]: + if not self.base_url: + raise RuntimeError("BACKOFFICE_MCP_REST_BASE_URL não configurado") + with httpx.Client(timeout=self.settings.rest_timeout_seconds, headers=self._headers()) as client: + resp = client.get(f"{self.base_url}/{path.lstrip('/')}", params=params) + resp.raise_for_status() + return resp.json() + + def post_json(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + if not self.base_url: + raise RuntimeError("BACKOFFICE_MCP_REST_BASE_URL não configurado") + with httpx.Client(timeout=self.settings.rest_timeout_seconds, headers=self._headers()) as client: + resp = client.post(f"{self.base_url}/{path.lstrip('/')}", json=payload) + resp.raise_for_status() + return resp.json() diff --git a/mcp_servers/backoffice_mcp_server/main.py b/mcp_servers/backoffice_mcp_server/main.py new file mode 100644 index 0000000..d62f8c7 --- /dev/null +++ b/mcp_servers/backoffice_mcp_server/main.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from typing import Any, Callable, Dict + +from fastapi import FastAPI, HTTPException + +from .models import ToolCallRequest, ToolCallResponse, ToolDefinition, ToolListResponse +from .settings import get_settings +from .store import ( + consultar_cliente_backoffice, + consultar_reclamacao, + registrar_acao_backoffice, + consultar_siebel_caso, + registrar_acao_siebel, + consultar_imdb_cliente, + consultar_speech_analytics, + consultar_tais_kb, + consultar_abrt, + consultar_portabilidade, + buscar_templates_emulador, + gerar_rascunho_emulador, +) + +app = FastAPI(title="Backoffice MCP HTTP Server", version="1.0.0") + +TOOLS: Dict[str, Dict[str, Any]] = { + "consultar_reclamacao": { + "description": "Consulta reclamação/protocolo de backoffice/ANATEL.", + "handler": consultar_reclamacao, + "input_schema": { + "type": "object", + "properties": { + "protocol_id": {"type": "string"}, + "customer_key": {"type": "string"}, + "interaction_key": {"type": "string"}, + }, + "required": ["protocol_id"], + }, + }, + "consultar_cliente_backoffice": { + "description": "Consulta contexto operacional do cliente para backoffice.", + "handler": consultar_cliente_backoffice, + "input_schema": { + "type": "object", + "properties": { + "customer_key": {"type": "string"}, + "contract_key": {"type": "string"}, + "session_key": {"type": "string"}, + "cpf": {"type": "string"}, + "cnpj": {"type": "string"}, + "document": {"type": "string"}, + "document_type": {"type": "string"}, + }, + "required": ["customer_key"], + }, + }, + "registrar_acao_backoffice": { + "description": "Registra ação operacional, parecer ou encaminhamento no sistema de backoffice.", + "handler": registrar_acao_backoffice, + "input_schema": { + "type": "object", + "properties": { + "protocol_id": {"type": "string"}, + "action_text": {"type": "string"}, + "operator_session": {"type": "string"}, + }, + "required": ["protocol_id", "action_text"], + }, + }, + "consultar_siebel_caso": { + "description": "Consulta caso/SR no Siebel para reclamação ANATEL/backoffice.", + "handler": consultar_siebel_caso, + "input_schema": {"type": "object", "properties": {"protocol_id": {"type": "string"}, "interaction_key": {"type": "string"}, "customer_key": {"type": "string"}}}, + }, + "registrar_acao_siebel": { + "description": "Registra ação, reclassificação, fechamento ou atualização no Siebel.", + "handler": registrar_acao_siebel, + "input_schema": {"type": "object", "properties": {"protocol_id": {"type": "string"}, "action_text": {"type": "string"}, "operator_session": {"type": "string"}}, "required": ["action_text"]}, + }, + "consultar_imdb_cliente": { + "description": "Consulta enriquecimento IMDB/PMID do cliente.", + "handler": consultar_imdb_cliente, + "input_schema": {"type": "object", "properties": {"customer_key": {"type": "string"}, "contract_key": {"type": "string"}, "session_key": {"type": "string"}, "cpf": {"type": "string"}, "cnpj": {"type": "string"}, "document": {"type": "string"}, "document_type": {"type": "string"}}}, + }, + "consultar_speech_analytics": { + "description": "Consulta histórico/resumo do Speech Analytics.", + "handler": consultar_speech_analytics, + "input_schema": {"type": "object", "properties": {"protocol_id": {"type": "string"}, "customer_key": {"type": "string"}, "interaction_key": {"type": "string"}, "cpf": {"type": "string"}, "cnpj": {"type": "string"}, "document": {"type": "string"}, "document_type": {"type": "string"}}}, + }, + "consultar_tais_kb": { + "description": "Consulta base TAIS KB/RAG e templates de resposta.", + "handler": consultar_tais_kb, + "input_schema": {"type": "object", "properties": {"query": {"type": "string"}, "protocol_id": {"type": "string"}, "customer_key": {"type": "string"}}}, + }, + "consultar_abrt": { + "description": "Consulta suporte ABRT associado ao cliente/caso.", + "handler": consultar_abrt, + "input_schema": {"type": "object", "properties": {"customer_key": {"type": "string"}, "protocol_id": {"type": "string"}, "cpf": {"type": "string"}, "cnpj": {"type": "string"}, "document": {"type": "string"}, "document_type": {"type": "string"}}}, + }, + "consultar_portabilidade": { + "description": "Consulta status de portabilidade.", + "handler": consultar_portabilidade, + "input_schema": {"type": "object", "properties": {"customer_key": {"type": "string"}, "contract_key": {"type": "string"}, "cpf": {"type": "string"}, "cnpj": {"type": "string"}, "document": {"type": "string"}, "document_type": {"type": "string"}}}, + }, + "buscar_templates_emulador": { + "description": "Busca templates/documentos para Response Emulator.", + "handler": buscar_templates_emulador, + "input_schema": {"type": "object", "properties": {"protocol_id": {"type": "string"}, "query": {"type": "string"}}}, + }, + "gerar_rascunho_emulador": { + "description": "Gera rascunho de resposta do Response Emulator.", + "handler": gerar_rascunho_emulador, + "input_schema": {"type": "object", "properties": {"protocol_id": {"type": "string"}, "selected_actions": {"type": "array"}, "operator_instructions": {"type": "string"}}}, + }, + +} + + +@app.get("/health") +def health() -> Dict[str, Any]: + settings = get_settings() + return { + "status": "ok", + "service": "backoffice_mcp_server", + "use_mock": settings.use_mock, + "backend_type": settings.backend_type, + "rest_configured": bool(settings.rest_base_url), + "oracle_configured": bool(settings.oracle_dsn and settings.oracle_user), + "tim_clients_configured": settings.tim_clients_configured(), + "tim_env": { + "PMID_API_HOST": bool(settings.pmid_api_host), + "SIEBEL_API_HOST": bool(settings.siebel_api_host), + "SPEECH_PREDICTION_BASE_URL": bool(settings.speech_prediction_base_url), + "SPEECH_HISTORY_BASE_URL": bool(settings.speech_history_base_url), + "TAIS_DB_DSN": bool(settings.tais_db_dsn), + "TAIS_GENAI_ENDPOINT": bool(settings.tais_genai_endpoint), + }, + "tools": list(TOOLS.keys()), + } + + +@app.get("/tools/list", response_model=ToolListResponse) +def list_tools() -> ToolListResponse: + return ToolListResponse( + tools=[ + ToolDefinition( + name=name, + description=cfg["description"], + input_schema=cfg["input_schema"], + ) + for name, cfg in TOOLS.items() + ] + ) + + +@app.post("/tools/call", response_model=ToolCallResponse) +def call_tool(req: ToolCallRequest) -> ToolCallResponse: + cfg = TOOLS.get(req.name) + if not cfg: + raise HTTPException(status_code=404, detail=f"Ferramenta não encontrada: {req.name}") + + handler: Callable[..., Dict[str, Any]] = cfg["handler"] + try: + schema_props = ((cfg.get("input_schema") or {}).get("properties") or {}) + allowed = set(schema_props.keys()) + # O MCPToolRouter do framework preserva original_context/extra_args por + # design. O servidor de domínio deve aceitar somente os parâmetros da + # tool, senão handlers Python falham com unexpected keyword argument. + arguments = dict(req.arguments or {}) + filtered_arguments = {k: v for k, v in arguments.items() if not allowed or k in allowed} + result = handler(**filtered_arguments) + return ToolCallResponse(ok=True, tool=req.name, result=result) + except TypeError as exc: + return ToolCallResponse(ok=False, tool=req.name, error=f"Parâmetros inválidos: {exc}") + except Exception as exc: # pragma: no cover + return ToolCallResponse(ok=False, tool=req.name, error=str(exc)) diff --git a/mcp_servers/backoffice_mcp_server/models.py b/mcp_servers/backoffice_mcp_server/models.py new file mode 100644 index 0000000..d96eed7 --- /dev/null +++ b/mcp_servers/backoffice_mcp_server/models.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from pydantic import BaseModel, Field, AliasChoices, ConfigDict + + +class ToolCallRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + # O client do seu framework envia {"tool_name": "..."}. + # Mantemos também "name" para chamadas manuais/curl. + name: str = Field( + ..., + validation_alias=AliasChoices("name", "tool_name"), + description="Nome da ferramenta a executar", + ) + arguments: Dict[str, Any] = Field(default_factory=dict) + context: Dict[str, Any] = Field(default_factory=dict) + + +class ToolCallResponse(BaseModel): + ok: bool + tool: str + result: Dict[str, Any] = Field(default_factory=dict) + error: Optional[str] = None + + +class ToolDefinition(BaseModel): + name: str + description: str + input_schema: Dict[str, Any] + + +class ToolListResponse(BaseModel): + tools: List[ToolDefinition] diff --git a/mcp_servers/backoffice_mcp_server/settings.py b/mcp_servers/backoffice_mcp_server/settings.py new file mode 100644 index 0000000..8f4f2b9 --- /dev/null +++ b/mcp_servers/backoffice_mcp_server/settings.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path +from typing import Literal + +from dotenv import load_dotenv +from pydantic import AliasChoices, Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Carrega o .env do projeto backoffice. Variáveis já exportadas no shell continuam com prioridade. +_PROJECT_ROOT = Path(__file__).resolve().parents[2] +load_dotenv(_PROJECT_ROOT / ".env", override=False) +load_dotenv(_PROJECT_ROOT / ".env.backoffice_mcp", override=False) + + +class BackofficeMCPSettings(BaseSettings): + """Configurações do MCP Server de Backoffice. + + Suporta dois estilos de configuração: + - novo/padronizado: BACKOFFICE_MCP_* + - original develop TIM: PMID_*, SIEBEL_*, SPEECH_*, TAIS_*, ABRT_*, PORTABILITY_* + + O default agora é usar os clients TIM originais quando não for explicitado mock, + para não mascarar erro de configuração com resposta simulada. + """ + + model_config = SettingsConfigDict( + env_file=str(_PROJECT_ROOT / ".env"), + extra="ignore", + case_sensitive=False, + ) + + host: str = Field(default="0.0.0.0", validation_alias=AliasChoices("BACKOFFICE_MCP_HOST", "MCP_HOST")) + port: int = Field(default=8010, validation_alias=AliasChoices("BACKOFFICE_MCP_PORT", "MCP_PORT")) + log_level: str = Field(default="info", validation_alias=AliasChoices("BACKOFFICE_MCP_LOG_LEVEL", "LOG_LEVEL")) + + use_mock: bool = Field(default=False, validation_alias=AliasChoices("BACKOFFICE_MCP_USE_MOCK", "MCP_USE_MOCK", "USE_MOCK")) + backend_type: Literal["mock", "tim_clients", "rest", "oracle"] = Field( + default="tim_clients", + validation_alias=AliasChoices("BACKOFFICE_MCP_BACKEND_TYPE", "MCP_BACKEND_TYPE"), + ) + + # REST genérico opcional. Não é usado quando backend_type=tim_clients. + rest_base_url: str = Field(default="", validation_alias=AliasChoices("BACKOFFICE_MCP_REST_BASE_URL", "BACKOFFICE_REST_BASE_URL")) + rest_timeout_seconds: float = Field(default=20.0, validation_alias=AliasChoices("BACKOFFICE_MCP_REST_TIMEOUT_SECONDS", "BACKOFFICE_REST_TIMEOUT_SECONDS")) + rest_api_key: str = Field(default="", validation_alias=AliasChoices("BACKOFFICE_MCP_REST_API_KEY", "BACKOFFICE_REST_API_KEY")) + rest_auth_header: str = Field(default="Authorization", validation_alias=AliasChoices("BACKOFFICE_MCP_REST_AUTH_HEADER", "BACKOFFICE_REST_AUTH_HEADER")) + + # Oracle backend opcional. + oracle_user: str = Field(default="", validation_alias=AliasChoices("BACKOFFICE_MCP_ORACLE_USER", "ORACLE_USER")) + oracle_password: str = Field(default="", validation_alias=AliasChoices("BACKOFFICE_MCP_ORACLE_PASSWORD", "ORACLE_PASSWORD")) + oracle_dsn: str = Field(default="", validation_alias=AliasChoices("BACKOFFICE_MCP_ORACLE_DSN", "ORACLE_DSN")) + oracle_wallet_location: str = Field(default="", validation_alias=AliasChoices("BACKOFFICE_MCP_ORACLE_WALLET_LOCATION", "ORACLE_WALLET_LOCATION")) + oracle_wallet_password: str = Field(default="", validation_alias=AliasChoices("BACKOFFICE_MCP_ORACLE_WALLET_PASSWORD", "ORACLE_WALLET_PASSWORD")) + + # TIM/develop original. Esses campos existem para health/debug e validação rápida. + pmid_api_host: str | None = Field(default=None, validation_alias=AliasChoices("PMID_API_HOST")) + siebel_api_host: str | None = Field(default=None, validation_alias=AliasChoices("SIEBEL_API_HOST")) + speech_prediction_base_url: str | None = Field(default=None, validation_alias=AliasChoices("SPEECH_PREDICTION_BASE_URL")) + speech_history_base_url: str | None = Field(default=None, validation_alias=AliasChoices("SPEECH_HISTORY_BASE_URL")) + tais_db_dsn: str | None = Field(default=None, validation_alias=AliasChoices("TAIS_DB_DSN")) + tais_genai_endpoint: str | None = Field(default=None, validation_alias=AliasChoices("TAIS_GENAI_ENDPOINT")) + + fail_open_on_backend_error: bool = Field(default=True, validation_alias=AliasChoices("BACKOFFICE_MCP_FAIL_OPEN_ON_BACKEND_ERROR", "MCP_FAIL_OPEN_ON_BACKEND_ERROR")) + + @field_validator("rest_base_url") + @classmethod + def validate_rest_base_url(cls, v: str) -> str: + value = (v or "").strip() + if value and not value.startswith(("http://", "https://")): + raise ValueError("BACKOFFICE_MCP_REST_BASE_URL precisa começar com http:// ou https://") + return value + + def tim_clients_configured(self) -> bool: + return bool(self.pmid_api_host or self.siebel_api_host or self.speech_prediction_base_url or self.tais_db_dsn) + + +@lru_cache(maxsize=1) +def get_settings() -> BackofficeMCPSettings: + return BackofficeMCPSettings() diff --git a/mcp_servers/backoffice_mcp_server/store.py b/mcp_servers/backoffice_mcp_server/store.py new file mode 100644 index 0000000..3310b0e --- /dev/null +++ b/mcp_servers/backoffice_mcp_server/store.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List +import asyncio + +from .clients import BackofficeRESTClient +from .settings import get_settings + + +def _jsonable(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return {str(k): _jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple, set)): + return [_jsonable(v) for v in value] + if hasattr(value, "model_dump"): + return _jsonable(value.model_dump()) + if hasattr(value, "dict"): + return _jsonable(value.dict()) + return str(value) + + +def _run_async(coro): + return asyncio.run(coro) + + +def _tim_clients_enabled() -> bool: + settings = get_settings() + return (not settings.use_mock) and settings.backend_type == "tim_clients" + + +def _tim_unavailable(reason: str) -> Dict[str, Any]: + return {"ok": False, "backend_type": "tim_clients", "source": "tim_clients", "reason": reason} + + +_RECLAMACOES: Dict[str, Dict[str, Any]] = { + "12345": { + "protocol_id": "12345", + "status": "em_analise", + "canal_origem": "ANATEL", + "motivo": "Contestação de cobrança em fatura", + "prioridade": "alta", + "prazo_sla_horas": 48, + "customer_key": "11999999999", + "contract_key": "3000131180", + "resumo": "Cliente questiona valor de serviço adicional na última fatura.", + }, + "98765": { + "protocol_id": "98765", + "status": "pendente_cliente", + "canal_origem": "Ouvidoria", + "motivo": "Falha recorrente de atendimento", + "prioridade": "media", + "prazo_sla_horas": 72, + "customer_key": "11888888888", + "contract_key": "3000999999", + "resumo": "Cliente solicita retorno formal sobre atendimento anterior.", + }, +} + +_CLIENTES: Dict[str, Dict[str, Any]] = { + "11999999999": { + "customer_key": "11999999999", + "nome": "Cliente Exemplo TIM", + "segmento": "consumer", + "plano": "TIM Controle Exemplo", + "contratos": ["3000131180"], + "flags": ["possui_reclamacao_aberta", "nao_ofertar_sem_contexto"], + "ultimas_interacoes": [ + {"data": "2026-06-01", "canal": "URA", "resumo": "Consulta de fatura."}, + {"data": "2026-06-03", "canal": "Backoffice", "resumo": "Abertura de análise."}, + ], + }, + "11888888888": { + "customer_key": "11888888888", + "nome": "Cliente Exemplo Ouvidoria", + "segmento": "consumer", + "plano": "TIM Pós Exemplo", + "contratos": ["3000999999"], + "flags": ["ouvidoria"], + "ultimas_interacoes": [], + }, +} + +_ACOES: List[Dict[str, Any]] = [] + + +def _backend_error(exc: Exception) -> Dict[str, Any]: + settings = get_settings() + if settings.fail_open_on_backend_error: + return {"ok": False, "backend_error": str(exc), "backend_type": settings.backend_type} + raise exc + + +def consultar_reclamacao(protocol_id: str | None, customer_key: str | None = None, interaction_key: str | None = None) -> Dict[str, Any]: + settings = get_settings() + if not settings.use_mock and settings.backend_type == "rest": + try: + return BackofficeRESTClient(settings).get_json("reclamacoes", {"protocol_id": protocol_id, "customer_key": customer_key, "interaction_key": interaction_key}) + except Exception as exc: + return _backend_error(exc) + if not settings.use_mock and settings.backend_type == "oracle": + return _backend_error(RuntimeError("Backend Oracle ainda precisa receber a query/procedure real do backoffice original")) + + key = protocol_id or interaction_key + if not key: + return {"found": False, "reason": "protocol_id ausente"} + data = _RECLAMACOES.get(str(key)) + if not data: + return {"found": False, "protocol_id": key, "reason": "reclamacao_nao_encontrada"} + if customer_key and data.get("customer_key") != customer_key: + return {"found": False, "protocol_id": key, "reason": "protocolo_nao_pertence_ao_cliente"} + return {"found": True, **data} + + +def consultar_cliente_backoffice(customer_key: str | None = None, contract_key: str | None = None, session_key: str | None = None, cpf: str | None = None, cnpj: str | None = None, document: str | None = None, document_type: str | None = None) -> Dict[str, Any]: + customer_key = customer_key or cpf or cnpj or document + settings = get_settings() + if _tim_clients_enabled(): + return consultar_imdb_cliente(customer_key=customer_key, contract_key=contract_key, session_key=session_key) + if not settings.use_mock and settings.backend_type == "rest": + try: + return BackofficeRESTClient(settings).get_json("clientes", {"customer_key": customer_key, "contract_key": contract_key, "session_key": session_key}) + except Exception as exc: + return _backend_error(exc) + if not settings.use_mock and settings.backend_type == "oracle": + return _backend_error(RuntimeError("Backend Oracle ainda precisa receber a query/procedure real do backoffice original")) + + if not customer_key: + return {"found": False, "reason": "customer_key ausente"} + data = _CLIENTES.get(str(customer_key)) + if not data: + return {"found": False, "customer_key": customer_key, "reason": "cliente_nao_encontrado"} + if contract_key and contract_key not in data.get("contratos", []): + return {"found": True, "warning": "contrato_nao_localizado_para_cliente", **data} + return {"found": True, "session_key": session_key, **data} + + +def registrar_acao_backoffice(protocol_id: str | None, action_text: str | None, operator_session: str | None = None) -> Dict[str, Any]: + settings = get_settings() + if not settings.use_mock and settings.backend_type == "rest": + try: + return BackofficeRESTClient(settings).post_json("acoes", {"protocol_id": protocol_id, "action_text": action_text, "operator_session": operator_session}) + except Exception as exc: + return _backend_error(exc) + if not settings.use_mock and settings.backend_type == "oracle": + return _backend_error(RuntimeError("Backend Oracle ainda precisa receber a query/procedure real do backoffice original")) + + if not protocol_id: + return {"registered": False, "reason": "protocol_id ausente"} + if not action_text: + return {"registered": False, "reason": "action_text ausente"} + action = { + "id": len(_ACOES) + 1, + "protocol_id": str(protocol_id), + "action_text": action_text, + "operator_session": operator_session, + "created_at": datetime.now(timezone.utc).isoformat(), + "status": "registrado", + } + _ACOES.append(action) + return {"registered": True, "action": action} + +# --------------------------------------------------------------------------- +# Tools adicionais reintroduzidas da branch funcional do backoffice original. +# Elas mantêm o mesmo padrão mock/rest/oracle do servidor convertido. Em modo +# REST, os endpoints esperados são configurados no serviço corporativo: +# /siebel/cases, /imdb/customer, /speech/history, /tais-kb/search, etc. +# Em modo Oracle, os pontos ficam preparados para procedures/queries reais. +# --------------------------------------------------------------------------- + +def _rest_or_mock(endpoint: str, params: Dict[str, Any], mock_payload: Dict[str, Any]) -> Dict[str, Any]: + settings = get_settings() + if not settings.use_mock and settings.backend_type == "rest": + try: + return BackofficeRESTClient(settings).get_json(endpoint, params) + except Exception as exc: + return _backend_error(exc) + if not settings.use_mock and settings.backend_type == "oracle": + return _backend_error(RuntimeError(f"Backend Oracle pendente para endpoint lógico {endpoint}")) + if not settings.use_mock and settings.backend_type == "tim_clients": + return _tim_unavailable(f"Tool {endpoint} ainda não tem client TIM direto mapeado; use tool específica ou configure BACKOFFICE_MCP_BACKEND_TYPE=rest com BACKOFFICE_MCP_REST_BASE_URL") + return mock_payload + + +def consultar_siebel_caso(protocol_id: str | None = None, interaction_key: str | None = None, customer_key: str | None = None) -> Dict[str, Any]: + case_id = protocol_id or interaction_key or "12345" + base = consultar_reclamacao(case_id, customer_key=customer_key, interaction_key=interaction_key) + mock = { + "found": bool(base.get("found")), + "source": "mock_siebel", + "case_id": case_id, + "sr_status": "open" if base.get("found") else "not_found", + "payload": base, + } + return _rest_or_mock("siebel/cases", {"protocol_id": protocol_id, "interaction_key": interaction_key, "customer_key": customer_key}, mock) + + +def registrar_acao_siebel(protocol_id: str | None = None, action_text: str | None = None, operator_session: str | None = None) -> Dict[str, Any]: + settings = get_settings() + payload = {"protocol_id": protocol_id, "action_text": action_text, "operator_session": operator_session} + if not settings.use_mock and settings.backend_type == "rest": + try: + return BackofficeRESTClient(settings).post_json("siebel/actions", payload) + except Exception as exc: + return _backend_error(exc) + if not settings.use_mock and settings.backend_type == "oracle": + return _backend_error(RuntimeError("Backend Oracle pendente para registrar_acao_siebel")) + result = registrar_acao_backoffice(protocol_id, action_text, operator_session) + return {"source": "mock_siebel", "siebel_registered": result.get("registered", False), **result} + + +def consultar_imdb_cliente(customer_key: str | None = None, contract_key: str | None = None, session_key: str | None = None, cpf: str | None = None, cnpj: str | None = None, document: str | None = None, document_type: str | None = None) -> Dict[str, Any]: + customer_key = customer_key or cpf or cnpj or document + settings = get_settings() + if _tim_clients_enabled(): + if not customer_key: + return {"found": False, "ok": False, "source": "tim_imdb", "reason": "customer_key/msisdn ausente"} + try: + from src.components.clients.imdb_client import ImdbClient + from src.core.config import settings as app_settings + data, http_meta = _run_async(ImdbClient().get_imdb_access_data_with_retry(str(customer_key), app_settings.PMID_API_CLIENT_ID)) + return { + "ok": True, + "found": data is not None, + "source": "tim_imdb", + "customer_key": customer_key, + "contract_key": contract_key, + "session_key": session_key, + "data": _jsonable(data), + "http_meta": _jsonable(http_meta), + } + except Exception as exc: + return _backend_error(exc) + data = consultar_cliente_backoffice(customer_key, contract_key, session_key) + mock = {"source": "mock_imdb", "pmid": f"PMID-{customer_key or 'UNKNOWN'}", **data} + return _rest_or_mock("imdb/customer", {"customer_key": customer_key, "contract_key": contract_key, "session_key": session_key}, mock) + + +def consultar_speech_analytics(protocol_id: str | None = None, customer_key: str | None = None, interaction_key: str | None = None, cpf: str | None = None, cnpj: str | None = None, document: str | None = None, document_type: str | None = None) -> Dict[str, Any]: + customer_key = customer_key or cpf or cnpj or document + if _tim_clients_enabled(): + if not customer_key: + return {"found": False, "ok": False, "source": "tim_speech_analytics", "reason": "customer_key/msisdn ausente"} + # A API de histórico precisa de CPF/CNPJ. Se o fluxo ainda não trouxe via IMDB, retornamos erro controlado. + social_sec_no = None + try: + imdb = consultar_imdb_cliente(customer_key=customer_key) + social_sec_no = (((imdb.get("data") or {}).get("socialSecNo")) if isinstance(imdb, dict) else None) + except Exception: + social_sec_no = None + if not social_sec_no: + return {"found": False, "ok": False, "source": "tim_speech_analytics", "reason": "socialSecNo ausente; consulte IMDB/PMID antes ou envie cpf_cnpj mascarado/canônico"} + try: + from src.components.clients.speech_analytics_client import SpeechAnalyticsClient + history, http_meta = _run_async(SpeechAnalyticsClient().get_history(str(social_sec_no), str(customer_key))) + return {"ok": True, "found": bool(history), "source": "tim_speech_analytics", "protocol_id": protocol_id or interaction_key, "customer_key": customer_key, "history": _jsonable(history), "http_meta": _jsonable(http_meta)} + except Exception as exc: + return _backend_error(exc) + mock = { + "found": True, + "source": "mock_speech_analytics", + "protocol_id": protocol_id or interaction_key, + "customer_key": customer_key, + "summary": "Histórico de fala simulado: cliente questionou cobrança e solicitou retorno formal.", + "sentiment": "neutral", + "events": [], + } + return _rest_or_mock("speech/history", {"protocol_id": protocol_id, "customer_key": customer_key, "interaction_key": interaction_key}, mock) + + +def consultar_tais_kb(query: str | None = None, protocol_id: str | None = None, customer_key: str | None = None) -> Dict[str, Any]: + if _tim_clients_enabled(): + q = query or protocol_id or customer_key + if not q: + return {"found": False, "ok": False, "source": "tim_tais_kb", "reason": "query/protocol_id/customer_key ausente"} + try: + from src.components.clients.tais_kb_client import TaisKbClient, Product + from src.core.config import settings as app_settings + result = _run_async(TaisKbClient().search_documents(str(q), product=Product.MOVEL, top_k=int(app_settings.TAIS_TOP_K), preprocess=True, postprocess=True)) + return {"ok": True, "found": True, "source": "tim_tais_kb", "query": q, "result": _jsonable(result)} + except Exception as exc: + return _backend_error(exc) + mock = { + "found": True, + "source": "mock_tais_kb", + "query": query or protocol_id or customer_key, + "documents": [ + {"id": "KB-ANATEL-001", "title": "Tratativa de contestação de cobrança", "score": 0.91}, + {"id": "TPL-RESP-001", "title": "Template de resposta formal ANATEL", "score": 0.87}, + ], + "templates": [ + {"id": "TPL-RESP-001", "text": "Prezado cliente, analisamos sua solicitação e registramos a tratativa..."} + ], + } + return _rest_or_mock("tais-kb/search", {"query": query, "protocol_id": protocol_id, "customer_key": customer_key}, mock) + + +def consultar_abrt(customer_key: str | None = None, protocol_id: str | None = None, social_sec_no: str | None = None) -> Dict[str, Any]: + if _tim_clients_enabled(): + if not customer_key or not social_sec_no: + return {"found": False, "ok": False, "source": "tim_abrt", "reason": "customer_key/msisdn e social_sec_no são obrigatórios para ABRT"} + try: + from src.components.clients.abrt_client import AbrtClient + data, http_meta = _run_async(AbrtClient().get_abrt_data_with_retry(str(social_sec_no), str(customer_key))) + return {"ok": True, "found": data is not None, "source": "tim_abrt", "customer_key": customer_key, "protocol_id": protocol_id, "data": _jsonable(data), "http_meta": _jsonable(http_meta)} + except Exception as exc: + return _backend_error(exc) + mock = {"found": True, "source": "mock_abrt", "customer_key": customer_key, "protocol_id": protocol_id, "has_open_case": False} + return _rest_or_mock("abrt/status", {"customer_key": customer_key, "protocol_id": protocol_id}, mock) + + +def consultar_portabilidade(customer_key: str | None = None, contract_key: str | None = None, social_sec_no: str | None = None) -> Dict[str, Any]: + if _tim_clients_enabled(): + if not customer_key or not social_sec_no: + return {"found": False, "ok": False, "source": "tim_portability", "reason": "customer_key/msisdn e social_sec_no são obrigatórios para Portabilidade"} + try: + from src.components.clients.portability_client import PortabilityClient + data, http_meta = _run_async(PortabilityClient().get_portability_history_with_retry(str(social_sec_no), str(customer_key))) + return {"ok": True, "found": bool(data), "source": "tim_portability", "customer_key": customer_key, "contract_key": contract_key, "data": _jsonable(data), "http_meta": _jsonable(http_meta)} + except Exception as exc: + return _backend_error(exc) + mock = {"found": True, "source": "mock_portability", "customer_key": customer_key, "contract_key": contract_key, "portability_status": "none"} + return _rest_or_mock("portability/status", {"customer_key": customer_key, "contract_key": contract_key}, mock) + + +def buscar_templates_emulador(protocol_id: str | None = None, query: str | None = None) -> Dict[str, Any]: + kb = consultar_tais_kb(query=query, protocol_id=protocol_id) + return {"source": "mock_emulator_rag", "found": True, "templates": kb.get("templates", []), "documents": kb.get("documents", [])} + + +def gerar_rascunho_emulador(protocol_id: str | None = None, selected_actions: list | None = None, operator_instructions: str | None = None) -> Dict[str, Any]: + actions = selected_actions or [] + text = operator_instructions or "Rascunho gerado a partir das evidências disponíveis." + return { + "draft_created": True, + "protocol_id": protocol_id, + "selected_actions": actions, + "draft": f"Resposta formal ao cliente: {text}", + "status": "draft", + } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e6494dd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "backoffice-convertido-framework" +version = "0.1.0" +description = "Backoffice convertido para o Agent Framework local" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "pydantic>=2.8.0", + "pydantic-settings>=2.4.0", + "python-dotenv>=1.0.1", + "langgraph>=0.2.60", + "langchain-core>=0.3.0", + "openai>=1.60.0", + "oci>=2.130.0", + "oracledb>=2.4.0", + "pymongo>=4.8.0", + "redis>=5.0.0", + "PyYAML>=6.0.2", + "langfuse>=3.0.0", + "httpx>=0.27.0", + + "motor>=3.3.0", + "aiokafka>=0.13.0", + "google-auth>=2.28.0", + "requests>=2.31.0", + "tenacity>=8.2.3", + "python-json-logger>=2.0.7", +] + +[tool.uv.sources] +agent-framework = { path = "../agent_framework", editable = true } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2148865 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,30 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +pydantic>=2.8.0 +pydantic-settings>=2.4.0 +python-dotenv>=1.0.1 +langgraph>=0.2.60 +langchain-core>=0.3.0 +openai>=1.60.0 +oci>=2.130.0 +oracledb>=2.4.0 +pymongo>=4.8.0 +redis>=5.0.0 +PyYAML>=6.0.2 + +langfuse>=3.0.0 +httpx>=0.27.0 +opentelemetry-api>=1.27.0 +opentelemetry-sdk>=1.27.0 +opentelemetry-exporter-otlp-proto-http>=1.27.0 + +pytest>=8.0.0 +pytest-asyncio>=0.23.0 +google-cloud-pubsub>=2.28.0 + +motor>=3.3.0 +aiokafka>=0.13.0 +google-auth>=2.28.0 +requests>=2.31.0 +tenacity>=8.2.3 +python-json-logger>=2.0.7 diff --git a/scripts/run_backoffice_mcp.sh b/scripts/run_backoffice_mcp.sh new file mode 100644 index 0000000..a5c1185 --- /dev/null +++ b/scripts/run_backoffice_mcp.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [ -f ".env" ]; then + set -a + source .env + set +a +fi + +if [ -f ".env.backoffice_mcp" ]; then + set -a + source .env.backoffice_mcp + set +a +fi + +HOST="${BACKOFFICE_MCP_HOST:-0.0.0.0}" +PORT="${BACKOFFICE_MCP_PORT:-8010}" +LOG_LEVEL="${BACKOFFICE_MCP_LOG_LEVEL:-info}" + +uvicorn mcp_servers.backoffice_mcp_server.main:app --host "$HOST" --port "$PORT" --log-level "$LOG_LEVEL" diff --git a/scripts_original_develop/.gitkeep b/scripts_original_develop/.gitkeep new file mode 100644 index 0000000..2899cf4 --- /dev/null +++ b/scripts_original_develop/.gitkeep @@ -0,0 +1 @@ +# Utility scripts will be added here diff --git a/scripts_original_develop/get_prompt_from_langfuse.py b/scripts_original_develop/get_prompt_from_langfuse.py new file mode 100644 index 0000000..c0f3e34 --- /dev/null +++ b/scripts_original_develop/get_prompt_from_langfuse.py @@ -0,0 +1,72 @@ +""" +Simple script to test Langfuse connection and retrieve a prompt. + +Usage: + uv run python scripts/get_prompt_from_langfuse.py +""" + +# Import settings and setup Langfuse environment variables +from src.core.config import settings +settings.setup_langfuse() + +from langfuse import Langfuse +from src.core.logging import setup_logging + +# Setup logging to see what's happening +logger = setup_logging(log_level=settings.LOG_LEVEL, log_format=settings.LOG_FORMAT) + +def main(): + try: + logger.info(f"Connecting to Langfuse at: {settings.LANGFUSE_BASE_URL}") + + # Initialize Langfuse client + # The client will use the environment variables exported by settings.setup_langfuse() + # or we can pass them explicitly as in the upload script. + client = Langfuse( + public_key=settings.LANGFUSE_PUBLIC_KEY, + secret_key=settings.LANGFUSE_SECRET_KEY, + host=settings.LANGFUSE_BASE_URL, + ) + + # List of prompts we know should exist (from upload_prompts_to_langfuse.py) + prompt_name = "ticket_reclassification_pt" + + logger.info(f"Attempting to retrieve prompt: '{prompt_name}'") + + + prompt = client.get_prompt(prompt_name, max_retries=2) + + logger.info( + "Prompt retrieved successfully", + extra={ + "prompt_name": prompt_name, + "version": prompt.version, + "labels": getattr(prompt, "labels", None), + }, + ) + + print("\n" + "=" * 50) + print(f"PROMPT: {prompt_name} | version={prompt.version} | labels={getattr(prompt, 'labels', 'N/A')}") + print("=" * 50) + print(prompt.prompt) + print("=" * 50 + "\n") + + except Exception as error: + error_type = type(error).__name__ + error_detail = str(error) if str(error) else "(no message)" + + logger.error( + "Failed to retrieve prompt", + extra={ + "prompt_name": prompt_name, + "error_type": error_type, + "error_detail": error_detail, + }, + exc_info=True, + ) + + finally: + client.flush() + +if __name__ == "__main__": + main() diff --git a/scripts_original_develop/upload_prompts_to_langfuse.py b/scripts_original_develop/upload_prompts_to_langfuse.py new file mode 100644 index 0000000..31927c4 --- /dev/null +++ b/scripts_original_develop/upload_prompts_to_langfuse.py @@ -0,0 +1,85 @@ +""" +Script to upload local prompts to Langfuse. + +Uploads the project prompts with the agreed names so that +prompt_manager can retrieve them via get_prompt_from_langfuse(). + +Usage: + uv run python scripts/upload_prompts_to_langfuse.py +""" + +# Import settings BEFORE the Langfuse SDK to ensure env vars are set +from src.core.config import settings +settings.setup_langfuse() + +from langfuse import Langfuse +from src.core.logging import setup_logging + +logger = setup_logging(log_level=settings.LOG_LEVEL, log_format=settings.LOG_FORMAT) + +# Mapping: Langfuse prompt name → local prompt variable +from src.agent.local_prompts.canceling_analysis import canceling_analysis_pt +from src.agent.local_prompts.ticket_reclassification import ticket_reclassification_pt +from src.agent.local_prompts.tim_complaint_analysis import tim_complaint_analysis_pt +from src.agent.local_prompts.emulator.response_emulator_generation import response_emulator_generation_pt + +PROMPTS_TO_UPLOAD = [ + { + "name": "ticket_reclassification_pt", + "prompt": ticket_reclassification_pt, + "description": "Unified reclassification prompt that recieves motives AND decides between (tratamento / reclassificar).", + }, + { + "name": "ticket_canceling_analysis_pt", + "prompt": canceling_analysis_pt, + "description": "Policy violation cancellation check prompt (alternative flow).", + }, + { + "name": "tim_complaint_analysis_pt", + "prompt": tim_complaint_analysis_pt, + "description": "TIM-specific complaint analysis prompt to determine if the complaint is about TIM, a different operator, or is undefined.", + }, + { + "name": "response_emulator_generation_pt", + "prompt": response_emulator_generation_pt, + "description": "Anatel response generation prompt for the emulator (opening/body/ombudsman structure).", + # Tunable history window read by generate_response_node via config. + "config": {"max_regeneration_history": 4}, + }, +] + +# Upload +def main(): + logger.info(f"Connecting to Langfuse at: {settings.LANGFUSE_BASE_URL}") + client = Langfuse( + public_key=settings.LANGFUSE_PUBLIC_KEY, + secret_key=settings.LANGFUSE_SECRET_KEY, + host=settings.LANGFUSE_BASE_URL, + ) + + for item in PROMPTS_TO_UPLOAD: + name = item["name"] + prompt_text = item["prompt"].strip() + description = item["description"] + config = {"description": description, **item.get("config", {})} + + logger.info(f"Uploading prompt '{name}'...") + try: + result = client.create_prompt( + name=name, + prompt=prompt_text, + labels=["production"], + config=config, + type="text", + ) + logger.info(f"Prompt '{name}' created/updated (version={result.version})") + except Exception as e: + logger.error(f"Failed to upload prompt '{name}': {e}") + + logger.info("Upload complete. Check the Langfuse dashboard to verify the prompts.") + client.flush() + + +if __name__ == "__main__": + main() + diff --git a/sh_original_develop/run_export.sh b/sh_original_develop/run_export.sh new file mode 100644 index 0000000..0149245 --- /dev/null +++ b/sh_original_develop/run_export.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +ORACLE_HOME=/opt/oracle/instantclient_21_9 +export PATH=$ORACLE_HOME:$PATH LD_LIBRARY_PATH=$ORACLE_HOME + +sqlplus -s admin/@ @export_data_pump.sql + +if [ $? -ne 0 ]; then + echo "Export falhou em $(date)" + exit 1 +else + echo "Export concluído com sucesso em $(date)" +fi \ No newline at end of file diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..0d608145130599f05174573869d311621f002636 GIT binary patch literal 8196 zcmeHMy-piJ5S~37#E*p1K|yf^6%C@NxFDxTqKZ-&FyCCrN{&14N~#~ zX?coBG z8i~KM12o7d8qxsslFq2#V(SHUKpjvA)B$xs9rzy{z%!f2O60lkqPnUB>cE?HfPWuS zOvcP(>CpakV6iO#u#3xf;4}IFo0EFXJeCe^D!$Wo55}h&-(nb-j(Ey+VCJ!O=+eo! zbTYoO@g0h>)sdH4I+@I&uIhj~&~|`(_jPpZeVWi0(ZBac7yHM0Bd*^;{Rv%(+R4%8QzTN?=S6DzsHoSu=X%RHmx9KqIcV&dcK_#OtTs}d0JW<%MDV+&z=I%#5 z7SU7XN{=eNRb13ftUnuc1z*llc|bQb1KvW(3~Niv-ZyAj$dBlICMW3I!W^*=WAC?B z+-Xxd_!;}jan94mDPxNm0aiov{N4*Ph0+B~p^v_DegSJsaSCZ#48qrhBZ=CX6 zM~xxscQUT8LmhYx4s_fmwz&WQ5P$#w8n&u?PzTh3hy$kEJMJAK=zHrHjeG4B^8}Lz u^Gb&{1&eLRfwmn7R{t=>=M+@V$vl=0aRuWaya=d0-Szoz4?&kV=fERccs`;4 literal 0 HcmV?d00001 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..94678b6 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,3 @@ +"""Python Agent Boilerplate - Estrutura base para agentes com LangGraph e LangChain.""" + +__version__ = "1.0.0" diff --git a/src/__pycache__/__init__.cpython-313.pyc b/src/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e4c5c466f5661746fb96bec5652cfd793fffc51b GIT binary patch literal 309 zcmYL^!AiqG5QdYMQnALH_r+TeX4|_6RYU}pN)J5_+s!7~HrWZY8>PMZ5I#g7$(#gV zK*$TYDT@DL=9^*oXQqv!5AVSupkNVFnDxf}-Sd3wax`p~ zDdX5R>OxUf2&dQuyS0vLSCe2XVHK-{C(+F*%OsZUL7420M3pmj;G0}%6YmDocoI*Z z#=T|;ke~!;MgT90tVRQ{58CdYX30zAc)}kS_iRqMR?=C1u!odHsa}ybfKv07kQ+=> zEfq*K-pA<#|7W-*$pN&{4j`_!&Cz9vNnNNb8u~rm^EUF~yB~((N3i@nJ$oC`z;}KC Dkwjf6 literal 0 HcmV?d00001 diff --git a/src/__pycache__/__init__.cpython-39.pyc b/src/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c93d7151297ac9363ef8ac7d856ae32c3aa8c3e1 GIT binary patch literal 302 zcmYjNO-sW-5KU4=QTrbjZ#|f8J&RaB@FJp!mm&<)-A%J5*$F!vOZ$)fOS$?lJh{oq z2lM7VW?&wJ)9FbPu~?peQtUs2`HwW3n^=4nNhhmBr>j(FZ^>-3e|_17lAKs>j0@tH zY;Am7V=&@EJaF(`=sb!wvJovtg~Og#^L+k1%lab# zO$wkt1^6Yy|k z8I3lg)u9Tg0)I^bp1WPl))6h~5p(xz{*JPFf0z{c(o)6*Z;Z@~IG$$36cJb`%JlU8 zpS(;D1jmk8>+{-5tHg`^xd&@%+5-xPK$VUwO8 z@alIyZ2c9!)dX$kzyI5X)UQSx>(?S0>Q@n7edB)2%tB43G^T7fWEKa_AXkwxuQ0ad_PV9(z6c>h0dKL7hkdZ!Ag0{=<@6U4)KfLC&R>&DCR uUK^r~(bzbyv?wX)+;%J%ycM6IiD4|{3t;51v(Gqz`n^n&HywIfCEmkBc%`@X0>Zn1 zGpJabs)Ft+svnI`RL1%IbUn@dS5MTk1l*XU2x-zRYe{z}pHF-2${fy*@+G%P+Ta>a|9kKXUMm5)h3v#q@(#uO;( zJzhNPMfOBMb1Mc$6D|<@;b>#j_a8K~lg-HAx*B?9n_@ zA5Vl%3=RjOWNdv;+yL1Ccg9lNp$Ht%HdL0udLwNAmJL9NAptDA%XPAD$(>JrGko-w GseS-7KS~w= literal 0 HcmV?d00001 diff --git a/src/agent/graphs_README_DISABLED.md b/src/agent/graphs_README_DISABLED.md new file mode 100644 index 0000000..43cf2a2 --- /dev/null +++ b/src/agent/graphs_README_DISABLED.md @@ -0,0 +1,5 @@ +# Disabled legacy graph package + +The original `src/agent/graphs` package was moved to `legacy_reference_disabled/original_develop/src_agent_graphs`. + +It is intentionally not importable by the active backend. The active backend builds and executes workflows through `app.workflows.backoffice_native_runtime.BackofficeNativeRuntime`, which uses the framework runtime and the original domain nodes/services/prompts as customizations. diff --git a/src/agent/local_prompts/__pycache__/canceling_analysis.cpython-313.pyc b/src/agent/local_prompts/__pycache__/canceling_analysis.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a246bb74348256800d4869a6f7ec089cb40762f3 GIT binary patch literal 2037 zcmZuyzmF3~6!sC6pp__*lJ3b=NX7=zp(xVEXOIzRpS*&~&&*-&o;P-i?wVsCqdX_G7Y$X0#Ivl{d$bLuFEX zwN$CF3;|=Tqv+~Y>Q9E#>c`1&oY3xo`thhgxF7EgM$>T= z_3N;b;{CxWPCkMW5v6_-L}pO}aDX+YA+-w7t-_;N2z6+X>SmYvW8kS??v81EXD~{J z2jhr{rdIe3@=UtaSYN$%9Z>8-?h(NRI#hbqYykxdEGeUFzEPQ%nrW0cvJ9HPRIlpH zvWJthD8amNUCuN(#4<-nW^PpdFeb-Do2ArFt&O`)Z71zoRY!)(>ex9|n0ANSsaaSs zc!X>V7i=cl9PO{~e}+Z8aGNNl(yA88VY?)Rux63VLUc%TnzMpR>6{VjBbXA#bPi4_ za*sGKcZ*ADJ)b!mSx-rJwjaCC3bpAN?(>e0b) zd_S&Ua84+n4DLRd#G~p(T#Euqg;#4-2hWMzOI8>qcpJ7}H0014b%9F=q}aq3a(QcL zVSI<#A1Ta9oRKLM8zjAsqw*<}Yi^`|;g)v|B?O;wwa` zTO$`%Ft9a?Z<*;V!1*ZqOh7gTfWZ9hDhpBhB$nr!40*T*?u;jU@wBD^#-GGU7(EC4 z#tx}$!x?><2c)I_>W#Zzij|>nsoU+gxy<%bS?6bU?q>Jdm%?P2ApRMLIQJ6N)=|%1 zH6Tk5|8BQN-~AU{5?4Plwu$t{>a)JQmq5=ocX)=Tcj&`H*vFZ9s+kd(p!&5~gq*vu zt|~(VxlN+Y<-um|v1*bMxe84a@<$!=yrJhR zO&as?9#NfS;&yZ$?;3(WNX_81p`vY_HX%`^YMG}UOY@XoaUh_t2dZQ+&ZhkBf!d1IIIESj8@DDj?hrCZTlX~ zyrBWJl!6QOJnQggQIt%^8evPR>)wR5v9{146~RG?_hM}3PQAbBL7Zqt3Lba{3d-dg z6ciMVuI-#&Y3hgIGoeNP4DCHD{c-CZ-;qF5}PJPj+^8-d^Z^aq-XA6?+kH{sl6-%hdn? literal 0 HcmV?d00001 diff --git a/src/agent/local_prompts/__pycache__/duplicate_analysis.cpython-313.pyc b/src/agent/local_prompts/__pycache__/duplicate_analysis.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d0c884ebc0cfaaec0d26876ab35fdd3fb163debd GIT binary patch literal 6923 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 literal 0 HcmV?d00001 diff --git a/src/agent/local_prompts/__pycache__/preprocess_tais_kb_query.cpython-313.pyc b/src/agent/local_prompts/__pycache__/preprocess_tais_kb_query.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d4486a58a4f131f0d1802180d35c89b3e8707681 GIT binary patch literal 2920 zcmZ`*OKu!D5Y;$FU^L)dz>5&t85qsL`2vn%VBk1`0_X!tUQepooHk`P*(SRuV;fl| zn;akqaJ&c6S^QaekT!|tH`5?fsm8HtnKy-AWB2$!C!N-v#g&P1*}dBh;RxNyFx z4YiStS}LK0L<-&+!t408N~#caMmU#BuL6$5(wOt$w+TlU$~<-)ltaq8!~sLDnUi%) zGYE~+yhUQF5J87Z6%@qL>|30vid1Epws}Chv&)HPoLD^DFZmQFist1?7hEm}we4xn z>s$jh!8ok(86zB^d`_01m&)SQQa!B|r$VPS62SxV0NQyQt4Ql0Q5NYSq`}I#2au2} zknjK@c1HOEK^#{cJAfDZDpAPp-r>Q~$!!1RA=)Vxb(iOU^V0!0!mGF!g;hAU^bR{R zVz88OLQ-&(_r0c8B}&m=s8wvuvU`ODYVyzC`;N9t?wgOzWqyst!qh9yz2=is!_BxF zdqQ6o=h*xS+#@j-WJqj6E{*2Y$D(w;IVx|1a=rst-^q@VhqmCcD8pX2%K`d&iv*rZC`$ zxeu3`x3Fje_heAbBx#qZ?J2}jH2;`Nmj<#xADh1{EC!8hx512KcUEwgfHnb`wrs#Y z_%FBlGiIQMGM-1%k~$u{u2lq=f@qY&*7G*)Nz(iRN~wkN&G}N9=+05ioe9;xV(VFG ztfBQ-+Sp>8&7(1ys zzcW+spb!WpiXPCa0_ZVe3Dc(K2Xl(71V((S!&f-RV~SLRT>#WbBYvj_w9JP7!GfyV z53dy(67QEX6;k7XgXV}Dei%w;b*h=znTpRK5FRrfGkTgEtc4mL1y)qenY3DR2nbrL zZldDrppaL*=v55Kq_p@d(LjQkQE027rsVn5s9)?vEZEx1mlfu&)RDKD1BE$nKN+-5^|KH}Gf4v#% z(ygW62)D%Lg{_ub3FBMG8g3(*dneo#x0MTX8IdW{r^VG(x_}L_2)fk3W$d^#$bc8m z!(oyQyBUy_^ooZF>oWSwunO9p(}b{;q>72nNYoI;inT@#R8^+2Ox+;fB`Rw(Ldj;&usQdmy`2Y{EYhF#)yt=L*Ej6ET8y}s0t>V;>5pf!^5j0VnvOsB>98o4? z8L^gTPGezCg-FOu1qHd*A~6)eGGd9RP-KCYsgWviI0;E`Fr}%?4EzKfE$r07XenkQ zqzM9(mkNQcZo<*r$RKMu&Il2Dy?&X5$R4?p$dpc{nG!GL=cj?8m9>}aYimui8Q|ko zY2#?YnMzPhi>%}QxtXd2`Gmrjk`Sk9BoW5j?{v0zeXrZ^_S|82^tC_Q9d^9kL2rkQ zqKS-*&@Cr_w+zg_QbF+>6<3tU2*wc#Wne&B1QEwv{961Dmnh1Jh(e}Ow~xZSoXM~j z6Sk&2gB|OIDnf<};Hm`{hn?-7+jooai|+>Hj$XMv3Ka#&H9?cm0}k0Kv4H{Jv$>AxU=C5Et=@B$|I0ACdwNM$G?PRlU;x`JkCo%>BkPAawy1|tt4LDI zF?xmOS|JFV)SVJ;VWKs(2wX!W@|d0DJTr0vrXxc*&NWJ3SqH-b<0{3)&u0>_f4+v8 zngb9?WK9xhU|vw7mqK&j2;QcR2`ShG^`o53y2ma2JUmS?Kjaz1U*pp>z;YKCaZqHd<>#Z*OM zDao=Smf|7;iHkR(JW}-Mw?EOW6nxp26nv5ySFRW%;jATRqe(eIM?UxI5F+8-S@hL6XsY1CTsH%hL?}1u{c%w#8-iAk2yF|6KF?( zvdK(nBSZDDr=97bvTH{QCy5Vua9m~WeNNnk6NW~t2*+;m& aeNwO2-z`0@FTefpV_N>B_Cmjh<-Y+HjfSHD literal 0 HcmV?d00001 diff --git a/src/agent/local_prompts/__pycache__/ticket_reclassification.cpython-313.pyc b/src/agent/local_prompts/__pycache__/ticket_reclassification.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f08979a5a1a78a125edcd4ff9502699af036afa1 GIT binary patch literal 2141 zcmai0&2HO95SG%Q4G8!JawrDM!Lh4SNt<4RqJZMag^NfIB-bcV6w;a;$s3bgW|vf) zzemWi4`B2VxIj)tpMX!$tFL{50DggfLyEN&JFN~9e`aUC`SzP{b-uRdHSoFHZ9RGQ zS)=i{+|-}u;^O=UE`Du{8n!WOZd}WMLZG*Ag5q~7PAQLxUCM!0k#?CPjw!QR&G5?< zM9Mim)qyJB7QYz^nJnXBEcbu1qfpV_PM;oF<#gnGqg+v@EKilBR*B?PmK>A1luKo~ zis*LVgop|iQZw~Ursrm(Tue`MrkJBcV^K7<>^K2FL#Z_f5y!}g2NpRjM=Dbx-f(14 zN8>q@$)#?NxDxV89{`tdz|IvX=+*bUTeoQRW0&^2!`^n+^X~f8Ex`-$^^jd)b((3D ztP{#CD)`=4K5dyeMZ*-BSVH5no9y{In!yda;dPQuwe!8NeHxWasElU@h08Y)SVexG zS4q%lpq-F?xzjc|5FFM4p{#LiX#yHD6{Br4@x6N=DKiC;Gfs+Ai~VeqP6A^s3#B<7 zntYB#CPyZUbDbHAfw_`K@lF&#iK0p(6E#;lQrLuXuxN$d9@f5jZeLp0_93Q zO;%sGXruCOBTWw(v?om6$Gt&!Brfs8Xm>#K;snEL7NnF)#8e303kkO96}47& zwSsOLTb}4-CgBfC!Sit%U6>2#H1=r@Uji44SU}Y!Nu0Fc(b>&)g5`>m$bS0NXHt|gntFfsvu)H z)#m1Xsu}#ZZL!t_6^oN&9UHoL=g!g!zs$9-i&Fsdxn%haf^l?Eo zZeo>WkZ|cwzBkxtp54U#5ohCNLCM)MPP5a-xg5%8FFJ9Ob;3@6v_p@rdBQ>FI_Nja z##4)2&x}19n?QFCRB&XbuwPiM0brY9`YvKR&IZ-GPGkZVjd3={DRO-Tf}cJ=yYVfC jCXe_#`zfHvp9HstulJuco6YyvzWDt5UyYmgI-dLkph@VK literal 0 HcmV?d00001 diff --git a/src/agent/local_prompts/__pycache__/tim_complaint_analysis.cpython-313.pyc b/src/agent/local_prompts/__pycache__/tim_complaint_analysis.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..309bc57bbbd271251a8d9ca689b5e4d246846cba GIT binary patch literal 4655 zcmb_g&u<&Y8Ksh-2o~@m#~ccLU=EQ0mXzHb3ZrO1SxTxPrUa5TdJ~7sAwBl)&T3|s zk(x9p&~r{n?s0kuV!+2dZgp>Ih8+Bk&?8zjFm`~ zb%JC{A}gQaMv^3Flvv@Tg(M^6M5Lnpq5QkF{DG5+jCERMDy}~5k#L2`7P?w;iEmPw zIZ2rywZN6)kFdnwG_SCbDV`QsO<{GVGr>3IccgP^#6lZE)6<7^WQ0{oKZ@Rak0yT@ z(sVdJ`DhqLlR{AW6K{{;Y_2ma<|RNK&K8)^hOjTWTjb(*H5wM@{Gn!I3v=tfV=$)`AH11GDL8z;` z;%|$|DDN$6egyRRT%7n650FT!J@*>VRCr2x}xILtT3gQudaj8rSaP^~K_i4hL zG-yV5mfDcYyydZ2y|Q}dhV6Sqo^i` z%Fxl)Qz^OzGzyFWZk<(Wm%gB&34F0dB@ar^KU9hzKdUEesJW34kW6JO{_e>5 znd4_eJ)}`uZ7vIV1yIF{P(7fGTuQ@o?rEo4wtZ#r5zx)!o3agopq1HX3i#CF3tOb9gQvq-;s&fO3`z$S!X|_nhe0z>;2vrnuOWb~@dj#ZX#XxPF<~&uzx>T1 zROjx!Ly94Su|wGQ;KsI>g?1ijQ|QZMO(%b3^^pW>V)`ZoX}}PEft~OlrbMo3){>8z zFMwRL#G)q9j^H46C1Zn{@yW+yK;yx<{AzmoU^t1QUtwZ;e0ZhzDr7Jo;K5vFqku+a zj>#z5kXV}SB4XB~S>0~F-t?DmZj^Y2^je?#3e5UGjr)A zLoCn~c5%*>&oQL);1?^P_G+9058FU(?;4Cmonf+|VXW{ygBvEbCJmQlNTRUibC0Nv4C7))q4%rlJbR6Xvd+&~R9^Q0i|h;v4r;LF z1n0WDO5Q!7QTf-irU&dn4G;p~1;@efF#4-t8NDzZ!EOdKuoP=R3c(}T1Zq)vz@~$J zI_7!c7o$0-3(i$-;ws@3Q4R3&Clrb2I2O9WFI3GtyxK~8cNgTb2Ub?h-H?Q>`PtF< z^yA^Q{Ca%KjQ8|wTcsVle8blawv8b>J!G*_KM%rc?159-T0a`M+!*Q?QpQ`s&2g3d zF{Uz~8w|54+$0xk!yVuUw+TgjwrO)mt$0CE!Tg{{S&`x7RGUc_pFsLu)MK46Zfa$o zH5g#FS4S`}w*P?)UMn$Rys_e8sMeLLh1ubITZ?yP&)I17p&f}@@j$8$o*D!~$|}Q> zaJx{OE`%l4pdK7|_yR~o2L#S7t}vpDnO=c{Pej3%`;K57rpm#&wEGR`7p67VXde_> zV$o7O94aBv*^0X0YQX9CYu!GLe65+_@GJj|)HFp8s@yI)wAD|jPfw>O=@^KLzG5`lpa~LA$^JyO(LhVqP4Iabk0pfzq zZ_eea@UyK^=}XTNfs=({cT{C0`~gzAa5f6fUyDB}$h3k>!7mPK<+)Q?r}~5?nw=5* zrp*7JCXEjEUcS5KyDs>rg=>r9o4X%7ukiCq=jHVv&D>xyfXk(Gqo2sw*#Uk#f!RjU zQ~8-TPcL+=26GWV)k~bqB#c~!{2lB{^UT1SIJ`GVa2QQ4P?9vqTDM2g&#zy;{agG< lQ6%zr<`;;BfA+C=@aN|@_xARFe&g_$Z~de5u6YZe{2RlMvxWcw literal 0 HcmV?d00001 diff --git a/src/agent/local_prompts/__pycache__/treatment_decision_prompt.cpython-313.pyc b/src/agent/local_prompts/__pycache__/treatment_decision_prompt.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ab6fc6fada159f97b780e205d561e470ceada07 GIT binary patch literal 4502 zcmb_g&u<$=6n095kflmodq5hwmn68h3S5W~$fQM0Ax??Ya*W2iV`tLco$bu7NqaQY~R^nBTRaPjGO6`P<)!h;&g(>}K$fXPR%2Z}p z6;`?2WU=fto0l$$9v^ig*0fq3Pn33R&C6k(G`X#?*{zAF)G_HGiW1VTN@S!>MmI^3 zCy?!+su?L;NQjeSXJfM?t|dw+7bOxVC=plBClG?q*c>IAw1$PQ94m{ndG)mVTRD-c zWY0iN$Sbf#ipXRW$W=BxIfziHn6PGT*zJVFyBr!D!xwle^h9>Vj!cKuVJdP1wRz13 zRoh}D??Yay$5w`GmvdVkw&{w(8q!g;uZte$HUNGl`CL z0Z(*JIBp$+PB@MuOg;no2yYZ|v79QG8c0_ntDYG6uTc9kC5t5h;>-#u ziOpH{6qf5OR_s->sVJ?4LzSKA>M3N&7^>k$0Au-fZ-A4sYVOxQCk4P9807=M& zpWq!w=%Ww=3`d z0j_W5hgGy<9c{wKvEQ|Vy!Ck4w<^r~m150Xk0x+sxrKDD-VAbd4H2T<9!UQy$))BLQy#?!q|cn7#;k<;S@j$Cyr4tNn0(Tn0>XEb9YPFripv;78oeB?6ER8lSBS)C$=K_kntVO~MM2Ay$P<_Q3tnHbdEi zG|u9wp5a<-oG|l

#^3-l&5pDIIv9_zlzv$LNd`GiYB+#D`oZQw=(zPo5efHzZ0? zZXEfJ>Ja<{3Cen2gr5^CCeWR99T^=+0o3oW#P3iNNo6G1o+)9RNUP^bp#fUb2xJ<> zgVsg~4pR{n%j#f`R>jAQj7Y2x5tPStKl8(2v$wq?uHV}1_222rS=A=k7*txSG_gsm z<4hZ`M2%tsHqe4@#6uqU6BQ?W3mpg1Ab{xkc4qd_BN$d{&+QrGd7g>hZqoO@#@3cc z(1%gz+nKf~3ysoDT!!`lwzTG4((+qTAi;c=qc&@urubp4H9w zg(8Ow>B|lFqBBFixs192;oOA1Im1aN5Sx?)M3%hZai7&&HYUa=>eAql7)_)1Rjq+9 z5vV40b5s)s3fLKNMz$Rs3d5wFLX?5U=0n(_OAud~U-Hlt2NlhB@@J;Sv=L#M-j3LN%p9rFWo%3zrDS5uS zacjLzjW)t4ZVa~iVtKH$y|KQtveta?peaOahNA)vT5Cr_*ZtMNjE@ z*g5|tl!1G6;P$ot)!sln6zg04o$&DWcCWwA&$s&D_P2iMw;=HmiEVMLPuz{(IV*OH zdpX~~(Ssei&2d!=fJ=5e}`l}R$w0==~}na;+w z$<0TPg8R6AiEMRTAb)u(SzY}`V+vxxuCA_$-jovmkFwAaej?*>!OsUL_}}R?Pr%io z;_wWTK=Tr=NQoXmA9IzBDgEW+-#m-5TV6i?-47!*Cruk1uq$UXt9-a#k1Xv%s}n1qz$vO0oANJX6}ZNXJnXMwB_%lwj6m+=E%1td8q>^V)%eIb7&Z z$8N0&*)4FG1sQYYNp*&5!=CeW0h?Q=5^jzZaN3{na}XK})HG_;c$^W+(tNdeo(X_& zrZI_oOD85Yn>$fdN9k9mkAxhbDm(3fQ-ztEXyoh@96$`iCfnoTi-rQ|7&^k!=hiuw z$Sr&hy~7^LHM~NhLVz%NlcN@o)g9){qaHMvHLAlX(@{`yo}BwDOD`^BrjiAYwT?Rsbk|GY82=i1-@W@zZYp%*WPEWSBI&7>rjP<6m-ZIuNjdjFWZyW0!W4&vv_l)(vv5qFzG5Z5!eQ2zY zjP-G1joZhKHDRntV@(L-dN|1wO}keu@>#~ z##%Dgvav20>!PtP8SApKJ~7r6V_h}Yim^U5mSe0{V^xe*HI{3vHDlF`wQelWSl5iT zVXV5b8pdiG%QsfbSl5je7%MbZTdhAv3C73g?LS6|Q9DX@$N#-3v1zBLf78B^jB?XX zPyeQ!p8iccJ^h<@dipo*^z?7q>FM9JZ<<dOW~xkYt%lP^zWGc<0za=*W6#t{6*64xc!&L`YU7owXyytv3|JL`P)M~ zf}z@PM*i;J!{cqYS@jxjv+YNt5dIT>GmIKu)eo#@_n&>Mg5Pc}a;-I|?gaSRupIdW zAGjl>d*y~-b?RRG*1eq#zwK?h(Xsng61X)lYzL?)a4U7E;dH;0Mw*W0x7@%%t4?%u zKm{o6uDVt$@Y{aHuj32$tnIYr|4EmUQHV{So{S0a{ zf^ZZrRM4usZ6~z+$nqOr7&>8RWCqg`w*02I>eaofgG#Ds%V|1wsgQG^^>;ox<2P%* z)pi>#Uwm#TW3gHe{*%p`pUs=}e!{8OtqJKv5FJF1&&oV-m|3rDfwP&+P_fDAgY1ho zhRSB^UM(r?Nv#1!g??L(pX-EKx@ZbD%m@Bz-ECmtP5iqSH7lNv@o}`6CCBQ15x5R| z6}rKugWs5e&}(BRa_iMOuoyUPhdT(P4^g`1dTl>Mr~CuXNH*-~6|{N0?q82w&mWbt ztH}?Ucss&(%@ETN&7$0R6n1}$4xv#O^VRm&yh<{>R?z)wQyjWGlieEntAT6fYYej* zU&^&Cjb$#Zf7RW@evdvx8Looa@>&@-tNEhp2D;AJ!XC%$T9^||kJtUukM?Y%iZ50+8)0-}+qYQ|!WV88 zYcF3UHKzM`z?Ty^n(9b%dBSHX$7MmC3e?lP;!4%&HTO-RE;xo9Nq) zPIO%Q#;BbHg(kX*pj_;1F(6!vkkhR0Kju=)yQpS1syfZ?mxy4Ig)uh6H8;p2P1|cn z$a5O-^PA9#-n#$C$!TJ|%GVM-gYV|uK*Fc&>%Pexf%kkHDW+qBH{%gY_m41o9i?ZQ zA${8%Dm}p0*%&0xyum1Ifkyp_pvdVieYvksqyA|4<_n zv-?C%(sRb#7Q{lbRr4E>8d4{U>h1@tx9AOYHK~A`OQl@*>rJ<=y3A>|y_!T1BX`}9 zUEa24>GWo^$3ub4QO~KMi0d8r)d(>sw`Htk14@qZu=H}CBvWD2!sS^$w*x?5b)urN zHMUiU6{ITFpvboXCq8&TpFNr4y!XGAdqz(4=hf zLS5OB!J=|286o3D&u=Tu5+;&$_7&^O!d7ALbY4CeC8~SWI+3Vw*<@HwDfAnJZCsT9|+q?%|N{uy2) zqL$v#Fo$Uvvk*R_hPCQ=xMhr5b-!Y|FWO2XP0jUdLH7$0?Mwh?FdVNc9?TC7Ps{z_feZQIX_S&Q`1Y zI|-#u^ft!C_?6yt%;c>(m9%vJ>>4G_hSS@U?EAhNa#NEC_xv8~Ja#T}lOZP4|17$a zK^z_#x6A=Ee)J^VHYRzZ-*a!*f{W{EkX&t<-#H{`2EQI9E9F|7PTfarMU8T`!9@aj zQg4L=mNQyRCnjGDny=!JNn8m{eH9R@t~8U#6ynA!Hx+3H3!4k6Sq^CVVVJu}%N-fl zc&ss|g*S7FUg&UcXp)u-gO%w?$HjA&UJp7)isjHJl?BL4`~Y2Ix6NT@ucHUi!i0E4 z4swa}suqVqdxjlR#RXTIH}|n^p+IhA%qk=bnQ44ahrVLFgp z`6{`%6}>Q^Kp}iF(PdSji#)VVw{0EZRvM8=k<5a;bOeEmhaOSz_y39$EQd?(_IeC? zqA1Wrg%&@1qoPdG^r-5?bXVdg>&?C3pyNLG zW1%DWsjWBze)Q&d^#lSexp6~_^?em4uUW|^%Wq+9leTkH-d^PBClW_Q9Ha@v7oMx+ zXz8sz-K6>NML}Haej%IGf$Ed<#|W1jUxclV)9K`&JJ76oBgutyZvX9{iY^JdU;DC$ zAW@`vtI?RjW2)QzJ3eW; zHRLjWQH3Z&%8B?o)7ON*ipxnuLL5S&1QTgM8)MMs)nXD4Z4FF}h6?Trqc*qz@`)3@ zhRL49dnMv$5>&J@XYi)i>(sU@0J4~!zCmH`k=@#mk+K)@VmN_IL=}&C)&YBdVuX8t z7B400PvZzf8@hzK%(h1^^iYSLl^UfG({*}bNMkD+E(nxlAlXIh4+l|+Wo*Q~nT`>= z?t2d{69t8vqGP@JZ@;zp$|!n$VE7h|&Xx;95N(E4!hZuORFO7JAad=H2RzYE)$UiT znA}L7Bj(a~Z(KLA`BBzwO0!tmM24q3owBBqhJxrMKDLtwJCpZye)Z?~NMPnoMqVU2 zltRQb)vofgs&_%YEqY<}1U@m_WFGtFpv0iaD9mNaLugt!y@*NYM=idEYdl73kXK0Xkce#NAx%fPT<}xf2Nzq zvgvs&Q0x-Y&aTD$rTy&4@V!UJ&yFunomrWkTbx;%vcDSo7S#>i`_)*Z*&eHo&Dt}P zBCffWb~q-}(q6&pHryM2u(5&!IJW9kHvBc*_uLg6HT-=I*F8TgGZqFF{ceoc#TCp~ zqa}4U5rpb1`p3impNCqv?v;<>PmI_^9|n)0L&NwtJoxm`P`qP!csPDwWBA#f|Ni8j ztwW#m%2(nY^7B9CXT4W$#6OXr@y?;$uWTLK=#}g7PYKEd*L&q4evsfHf{VTKrTAfj zM+p30xfSmsc$DBuuY5KB8Np)&jb6DKKThxjLDVa6#y=EWf)?}vsSTrR~=kv|Q-b)wcQugA|2JewArX2FxC_&M_DS@2q~yb-@Z@FKxxuY4nZ ziNG4#y?1NO>6KUGmkIU|aAdC#>?Ppr>?3%Upwlbgj`tHBAXx8}y?BJ+HG&(x^3C{l zf>8hr%gtgcTom#OfIx~^uf4!^oxidWS2Y34F*IqF)? zT<7@JLMg7OtD3o{cgV>)xg&PfwU)WA@~f3nTvOLNU0aRYz4B+Vr|eo*{5*>?>+1I@>GH#n%-Dik5rj3vsBZ4fNjDB!fq!Xj2h`)D1;9 z74b0NQq&QI9Wgb0mff2x#kbY-nHJ{$zN6@KMT}W@6@6S{!*^wp#OHU}8 zEEb$ndP3<^ue=;jD>|v@(_Yz$XB3@M#A!LL=nN1hFYJ}u@vNe=f-safOpKdT$Gn#H zdgW{JIYkSK7)otLi;B1$=M^mhy}UI_w5;fYqN}~~N_s-kMI?8dI5HAM^oHAU-+++KMt_7q)H#EINc zR9D2AXeeqb;tKeRT8h}>bwz<9&P1rFt>}EOyc9=@HWhJy-B5H>5%buEsNp zPAOvbrxl%1#1)!VbXE~3XHL<)B4(v?iWU^D^~$x_RV z&>94JtwE628U%T*L6FxP1bMANkk=Xnd96V(Kx>fawFbcetwEmG8U%T*L6FxP1bdk7 zRcjFBwFW_6YY^nM20>nH5Ny{P#Cfejkk=Xn1GENtUTY8x&>G|ev<7)zYY^nM20>nH z5DeBDbPd)TboFTsvcXz|u0E|n)~7Yd`m_ewV68#dV68#dV68#dV68#dV68#dV68#d zV68#dV68#dV68#dV68#dV68#dV68#dV68z{pVlDDX$>PyHiqdcY7L^I)*vcs4Wgpf zAS!ANqN3IyDrya)fm(x}qShcPY7L@oT7&dB5(T7BqBV%NX${h%)*vcs4WgpfAS!AN zqCr}Nj-u8eDrya)qShc1t?}B{5vH2u5|L;PAl@o3DiW;$#9PQ^MWQu;DE+P|60PwX zl+fGb)O5$8hSed817(}NvL8E&L~#I75>yn4;sB!Za}|l=NK4feiQ)jFpz#!m;z-ek zB2gSb>~KSoC=MW|OJ9*F4j?Az>xx8i08!(FiZ)C2;obSGx%|wfOZizymjwBANsv#M z1o?DHkWZHc`E*H;PnQJwbV)EEU6SY1CBcAnNuE!a1o?DHkWZHcdvfWLAfGM?^68Qw zpDqdV>5^c3x+Ko0OM-m5BtW{v%K?SbTLj3HshU1QfIJC6iT4fx5+neX^m_!zi~!Wp zM+uM_0jLN*AV6jWU@IRHNM;1##u*|0ogg`tA(~2Yk0#O{# zD3bgK#0F0*lJo~eaXhO?vL6t|@th*bdO#G%=M+h{1EM-kx@qBuUUNU|Fcb?LGq z$!b89y%!ZpHUpyUy{t&G7!Wn?6-AQ0(z;d@N!9|=TZkgbRzMtMMUiAFARby*kz^+z zYTTM4$x1-fxSk?ON_{Uh8|smql)lk46iH6f!seC6SC6D7^_X{>>*|pVg=!f8Lq(FI zfEaWlMUtU_7}IVjk_@$H>mV2RmLkbe{VzDT)g>t^ahVsKJL-~DH8GsL;M`Rvxhg4c zUU0@4zhX&PQk*+o$yh+TBNa); z+V=LNF3DMmD|`D= 75 → decision = "duplicada". +Caso contrário (ou nenhum item passou em A) → decision = "nova". + +## FORMATO DE SAÍDA +Retorne APENAS o JSON, sem markdown, preâmbulo ou comentários. + +{ + "decision": "duplicada" | "nova", + "reasoning": "...", + "case_response": "...", + "duplicate_match": { + "matched_protocol": "string", + "similarity_percent": 0..100 + } +} + +REGRAS DE SAÍDA: +- `duplicate_match` SÓ deve aparecer no JSON quando `decision` == "duplicada". + Quando `decision` == "nova", OMITA inteiramente a chave `duplicate_match` + (não inclua como `null`). +- `case_response` SÓ deve aparecer no JSON quando `decision` == "duplicada". + Quando `decision` == "nova", OMITA inteiramente a chave `case_response`. +- `matched_protocol` deve existir em `complaint_history`. Não invente. + +REASONING (máx 400 chars, PT-BR, formal, descritivo, autoexplicativo para +leitor sem acesso ao prompt, sem transcrever a descrição do cliente, sem +inventar protocolos, datas, valores ou operadoras): +- Conteúdo: raciocínio analítico desta verificação de duplicidade. NÃO use a + forma "Solicito o cancelamento..." — descreva a análise (itens descartados, + candidatos sobreviventes, pontuação, conclusão). +- NÃO repita a palavra "duplicidade" no texto — quando há cancelamento, o + rótulo "Duplicidade" é anexado pelo sistema antes deste reasoning. +- NÃO use termos técnicos do prompt (ex.: "sufixo", "filtros", "ETAPA A/B", + "rubrica", "limiar", identificadores como "A.1"/"B.4"). Use termos de + domínio que descrevem o objeto avaliado: "telefone reclamado", "msisdn", + "número de telefone", "CPF do cliente", "status do chamado", "pontuação + de similaridade". +- Mencionar quantidade de itens só se determinante (ex.: histórico vazio). +- Itens descartados: justificar em linguagem natural (cliente diferente, + status incompatível, fora da janela de 30 dias, telefone reclamado distinto). +- Candidatos sobreviventes: pontos de similaridade e pontuação total. +- Encerrar com a conclusão objetiva da análise. + +CASE_RESPONSE (máx 200 chars, PT-BR, formal, autoexplicativo para leitor +externo — equipe TIM / regulador. Diferente do `reasoning` analítico, este +texto é a mensagem publicada externamente): +- Tom de SOLICITAÇÃO de cancelamento. Inicie por + "Solicito o cancelamento desta ID, pois...". +- Conteúdo permitido: apenas afirmar que existe uma reclamação idêntica + identificada pelo `matched_protocol`. Opcionalmente, citar 1–2 pontos + de coincidência verificados pela análise (ex.: "mesmo cliente, mesmo + número reclamado e mesmo motivo"). +- PROIBIDO inferir qualquer coisa sobre a reclamação anterior além da + existência e da coincidência: NÃO afirme que ela foi "tratada", + "resolvida", "encerrada", "atendida", "aberta", "em andamento", "já + respondida", nem qualquer adjetivação de status, desfecho ou histórico + de atendimento. A análise verificou apenas coincidência de + identificadores e similaridade textual — NÃO verificou o estado nem o + tratamento do `matched_protocol`. +- NÃO inclua pontuação numérica, rótulos técnicos do prompt (ETAPA, B.x, + rubrica), nem repita a palavra "duplicidade" — o rótulo "Duplicidade" é + anexado pelo sistema antes deste texto. +- NÃO transcreva a descrição literal do cliente; descreva apenas a + coincidência verificada. +""" diff --git a/src/agent/local_prompts/emulator/__init__.py b/src/agent/local_prompts/emulator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agent/local_prompts/emulator/__pycache__/__init__.cpython-313.pyc b/src/agent/local_prompts/emulator/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2c14817ac966ed6c461a71fd5658d781fb2a5869 GIT binary patch literal 213 zcmYL@K?=e!5JelSAVTjT?%X)I7r~_qMeqP2rm3V&GG&@79>ha&2GTy-9-^N_>txSbvdOC@~+;;fwK z1qrLJNEJt^;Ur{*MWLnQ65U0S*E#-gpdtD3fk7z@uL*4(ekt1;;gNuI?X>57QQx}p W%3|IcwWd)B5koc+UMXXY(m-#ws5}1v literal 0 HcmV?d00001 diff --git a/src/agent/local_prompts/emulator/__pycache__/response_emulator_generation.cpython-313.pyc b/src/agent/local_prompts/emulator/__pycache__/response_emulator_generation.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0f83abb9f754b8fca92e5ebd852aa2a7dc06f812 GIT binary patch literal 10955 zcmb7~%WoW4cE%~mvImWs#4|83d1CIglYj)srmRc?fzD*mrrHVzBAaH@kHO4Oo`yX(2ftJZ4TLs<+`F-c!>S~ILF(wSF zyQ}Ix&iT%FzH@GU@r`e^R``3P+rIbT8!IdSr7!j0weaDKKjOnLRt77@%HT?IWpK5) zI=EI`8@yD!^pk5pd1-LHbG5iWc)57_?3J~zRe!+E{dE3l-F=cpV@pr2qapRzhT$!iIFqua4U(Wv}cc*#ciuf!kr$sc<#S@piNs&+UVV>sh zlVLGFs=qS!&TW3m$CETlvT1XnQ*HekqkqZcXIEBM-phycU%4_mNg`W(`@Rn1#N79!<;cWb%X7Aa*kwDLfes&6Xt>()(o`!oI|i z>n=^sc=Cdk*emY)N>+G*3D&MYePxoDLc`NnM)7G>rPJ!~{%~cbnuSs6Cig=WqC56AhIHk+kc!D4BAuvg! zvBvwixLk@tdCPS(Y?*diEye6KDp;jsU1xC-6|TGA+v^To-);8~_WN3d&8&q+ATmu1 zZU6a~|3ENLL5iG_WW!{_it$Ek6qmyyp2fOcLo$y*Kj%yu2O zoo7S#Tf`eB40(T;R%J5tqp~NSVxakFJkNiPb@0UNuVe4M!`=sneRsR(dV7Zl-P_$( z01e)Zv5%No;Ex`pc{Ezr9)wrulGT|-%B@!GjgC7WMrC~LA;ErOv zAE@|vYl)S$H-D$Xp;@CQ-agpf93-qH?YRA%%QN7YRcYR-2dzph85fMGgyB}@$2IIZ zIo-}C_txDQ^N+H1fRo0jc@~!tN;ZPj=D*DAG3pe+Ad7TRFV(F!IAFAVL3dl?e5t3hu#EiAYyJ z@Fj#5-!#Z-uPm{=f7UQ(4kji##E?6 z#%DZS#1k^A%%4Ek0CoYt&RXjxzAmxla2k(}qM`O(dcW79FJz0uEt46A+Fs7#Sc4J~ zXHG7hIbac=+e+Dq@*Eba*a@tK$4A2dX%0UTSr|5v3CCDWC{v&vPajO;V_?XZZg$@A z-`)4W@7faxD<)#&QM*|paXN$~()>*G31hnhgD8&+rhZRH$>k+c@9^qMS!~Y?CNRXmy zuFY6+q1&)ZHviNecWstEg5ucNqaNv)J1c%$T^cE$*m5_G6O+uW3AQeJXszSk@c@LY z`~zaNJUu7;9^0P;q?It5gUyQ2xTKGa22m-4CK7~34jJA3l|~~>6aHT@M!JBd}17&u7e)ty}&3k zyr{}1Iuaq{MbFEAia_z+boFGga8#f>mD*4{%`WTUL^}V{4ecn} z%rWMwH6w%Nu}E+kV<;IUMLaE%hav)js?KSv?NOZEC!vyv5qm^?ok!d2&6?Wr{Rx^m zDmtA`d)>9etoGGtAmTFa*@#LIVaaWnPZC?Tl5aB-M2N9drQ3K*(3Hsjs6u3bSYmIO zl#+JR3$@t@H|W9pI&DV6c2ycp=bsWNf#?+#g&WU5OQ*>sCDfTLD{I#fn-bEIVQUGT zMa=wVVkYdg{ur+M@B}wX;*+sFaE9kq_Mw?>al!4pvqR{aL>B@*h&;WVROQfSn4v<^ zpWr4hpn*EejpdR8Mw}Uk69t41>&lEt!_PtpNof-02 z%sD2*?m`bCTQAu>CFS2pAw(4k`&S5~g+YJ^$~&Q=un$Egy<=20kX||Hz1=&Q|NY+f zPS?g;a`v)s?SbxLZ*TrLNS!S=u1w^R`IL*|`!X+^jexf4O4r^m;)mF0HCkK9w;wAx z{nT(+r`Fv4_=BHrwOzbP4H6?`3MDCtF~4OvGioZNj@!Aj*FWg7qcSRG#X6TsC)JN* zHTg_gHS_xy4?BBn{3KWYLSVr8l8GVPdt@`ku$w zIU+AF9vqz~XXi(dW)Z|t1GYLA%b+Sv;(>R5cW?D}Z9`Bu1rB72jFI|Hrx9A_g@|-D z96N5;__aKbCen)<=sDD{awTKb1+ob2O90sK4|WcB-s_4}*syu3*>FtKsq3~;O!_>J z&B~@3hVxG+NwkDl<7Wt@pi0oUY zM-4^^ki)uDChj7k3^__Ob%qXlZ$9b^N+te7O>nnhDOrD?IixJhRjS<3n+1(V1+5B+S9o@$Y>W+)HB zoSar_pIRK!&Np_55vyh4bH6A*j-{Zw^f6d_sk{Zq^@O6_lG~UMK-|I}BNXwy*yk@k zhU_8P8#vvv4T&Z}Y^=_oXc;sW*65bFkcd)t^5n388?@kYGoWbMHlg~)+^LL)P_-DPM^gmx68INc zqP4ZXq;pLwRG=;kHKWLkI+m2{4s<2nE%VOQOoQTtlF?Sl7+}@h<(tuCL^{2el>J)HhyqzH*h;DVc%g zsT(G78RkG08f|)s1amIup$iq|Lab^ifkoX_^YbW-)fk^73+j~3v}K+^!O^)Jz+0H-|B}h&`Y@x*bvsrFo8B z!--by;iJHN zYP)vZM6rC}?`fK{=va1@WRq&zY3)@R1gC-mEuYeMKF?Faz;3A0B2*C@KH7Au2qE6fi)J4!zJYgFE zfC`Qa<#$+z&Z8RBKa=CU;913`!;Xt*J|^j~C`-|7gq7lXX;pqU z-^>MyO%_;ff5l@2O=X5)E&?$n@F`dkDs~hF$6V<3yUoR^jzXZBSMIM3^IW%AYrU-) z))@}m;MzaIOZPl|7gGmC&0+BtfO7 zikcN1ieX@DviU6V8BJxjf~%k(s%ETjM{nG1TEl0IJncGJi z2}_iDsy>>6X@WZhCP4AA-dI?QI-b}3eT9+sRVF;Ex@ zQp_@riVsKm$DBv_{Qy~$>-@_a?-47o$57>@br}4rXdIowu-fz?2WA;x)pNH ztsB7c=FJ;CkVZ3t(_$K}*1df&|3z=WZhYC-Y9TAhfvUxPaFDH-Pr?edMyE_2UY0|5 zo~E8YYe^?nkq^mrI*q-`-yq$&lS2A)<8HTnk)+JciP zX0eepM2H_zRhe^mu;JQ=aKQpBO5wQCi-XESzikXl#Y-P&zO#V2kZEyh!0hgX)CZW>dtdc zhAP(6f%toS$f!X1A=(!wi#DLedwtM)e4!vW2OSd1Qa;buLO}k)-5r493wb)ewo?J4 zE|tjE=rFj-Dl>obRmPP&zEr{aLm63@T`ii~O13&ZL?bekPC^NP-vl?t(OU!%muwK) z=ixjp5FZT>>s1Kx$P?dCWF(x-F6_je-k};6U{*#SnFB2BvAzF=-V4I=V26I_Kp}s8 zY0FDhyy94ul}hc9cIu7=cJV$o5b3C}De{=!n@z>F*K8pQ8K*Ag>J|&Fc^OJVMzS2S ztyXR^e`2SL9J{JjOi1hh#r*M1<(rgGsea{BZS1RgOrzA8NW$Q8j=`i$LS7hE1Y6xP zp<Us3K9-QgQ zWmehnLK8_@%Q(*~^1k8DD_(^Hc}>>c^8{d86l-8e6s$;B4eD}Or4uYpmu3+ui)Hz( zT9fc%0x>YO3pMKtosuiRGW5i^x9k^16nFTFUAK^5F^9d^v%t9;C)ITRuc+rhO>g%P zcDw#1h(UM$bG-v`U@1DT8=OBJ^!MCs{s{i1Un!bvQfwf-`9F_Iwn`+QFeL?iC9hD!ZMa+l`sr%vp$@8_FceFhyqqBMrDE)IP19+&ekS%scZ;OV z{$(_OGKy6Ep*`&A+4~i*#`nX^Gw{h`1IO}b-)kzI7o?tYE!>r4XWk_9X@F)=)5tkGytTHwXrOQXf)| ztPUc>3G*FZ&1m!~S;v?$P@@hktQI`TO+SmoDw~?ZP+v@4mNl zyMM6LJ>tA(zk8?ubm^zty|=sX-aUN!T65Q1KRO!p?x+p%^g9MW+6YJSSf{Odan!WD zk0#T{D_^Ye_w;W!$Jumqw7EOjar;Gnk5@g)O}gm3B1z^N#~`7c9pU#;T{^{IUggCJ&x|?az4Mrb^>KFMflC@lxeV zxyS#WUAuDS%Cp~Fd2Q?Sl~", + "id_procs": ["", ""] +} + +Regras do JSON: +- "conteudo": deve conter apenas a orientação objetiva ao operador +- "id_procs": deve conter apenas IDs realmente utilizados na resposta +- no conteudo, referencie documentos a partir do título, entre aspas, e não pelo ID. +- não retornar texto fora do JSON +- não usar markdown +- não usar ```json + +9. Se a QUERY estiver fora do escopo ou envolver: +- reclamação na Anatel +- processo judicial +- assuntos não relacionados a procedimentos internos TIM + +retorne: + +{ + "conteudo": "Desculpe, não posso responder sobre esse assunto. Mas estou à disposição para informações sobre os procedimentos da TIM.", + "id_procs": [] +} + +10. A resposta deve iniciar de forma contextualizada e objetiva, por exemplo: +- "Olá, para este caso..." +- "Olá, os procedimentos mais aderentes são..." +- "Olá, conforme os documentos localizados..." + +11. Priorize respostas curtas e documentais. +Evite detalhamento excessivo do conteúdo dos procedimentos. +Prefira indicar os procedimentos relevantes ao invés de explicar integralmente como executá-los. + +--- + +""" \ No newline at end of file diff --git a/src/agent/local_prompts/preprocess_tais_kb_query.py b/src/agent/local_prompts/preprocess_tais_kb_query.py new file mode 100644 index 0000000..a28714a --- /dev/null +++ b/src/agent/local_prompts/preprocess_tais_kb_query.py @@ -0,0 +1,84 @@ +preprocess_tais_kb_query_pt = """ +Contexto: +Analise a transcrição de um atendimento relacionado a serviços da TIM. + +O objetivo é gerar uma reformulação leve e controlada da necessidade apresentada, de forma a melhorar a recuperação de documentos internos em sistemas RAG e embeddings. + +A reformulação deve preservar ao máximo a intenção original do atendimento, realizando apenas pequenos ajustes para tornar a busca mais clara, objetiva e aderente a termos operacionais internos. + +IMPORTANTE: +A função NÃO é reinterpretar profundamente o caso. +A função NÃO é expandir excessivamente a query. +A função NÃO é inventar contexto, fluxos ou problemas. + +A reformulação deve parecer próxima da fala original do cliente, porém: +- mais limpa; +- mais objetiva; +- menos ambígua; +- levemente orientada a linguagem operacional/backoffice. + +Regras: + +1. Preserve a intenção original da transcrição. +Não altere o significado principal. +Não adicione problemas, serviços ou sintomas não mencionados. + +2. Faça apenas alterações discretas e pequenas, como: +- remoção de ruído conversacional; +- remoção de saudações; +- remoção de validações cadastrais; +- correção leve de ambiguidades; +- normalização de termos; +- reorganização da frase para melhorar busca semântica. + +3. Use terminologia operacional apenas quando houver forte evidência na transcrição. +Evite expansão excessiva. +Evite adicionar múltiplos sinônimos. +Evite listas extensas de termos relacionados. + +4. Priorize precisão semântica sobre abrangência. +É preferível uma query mais curta e fiel do que uma query longa e altamente inferida. + +5. Introduza discretamente linguagem mais aderente a ambiente operacional/backoffice quando apropriado, por exemplo: +- ativação +- cancelamento +- falha +- contestação +- autenticação +- faturamento +- migração +- desbloqueio +- indisponibilidade +- inconsistência cadastral +- suporte técnico +- procedimento +- validação +- rede +- login +- cobrança + +Mas apenas se esses conceitos estiverem implícitos ou claramente presentes na transcrição. + +6. Não transforme a query em passo a passo, resumo técnico ou descrição extensa. + +7. A saída deve conter apenas UMA frase objetiva e pesquisável. + +Campo "original": +- Deve conter a transcrição original sem alterações. + +Campo "reformulado": +- Deve conter uma reformulação leve, objetiva e semanticamente otimizada. +- Deve permanecer próxima do texto original. +- Deve ter baixa inferência. +- Deve priorizar aderência documental e operacional. + +Retorne APENAS um JSON válido no formato: + +{ + "original": "", + "reformulado": "" +} + +--- + +""" \ No newline at end of file diff --git a/src/agent/local_prompts/speech_history_analysis.py b/src/agent/local_prompts/speech_history_analysis.py new file mode 100644 index 0000000..fa1ce31 --- /dev/null +++ b/src/agent/local_prompts/speech_history_analysis.py @@ -0,0 +1,47 @@ +""" +Prompt template for scoring similarity between the current complaint description +and each historical complaint retrieved from Speech Analytics. + +The LLM scores each historical item with a similarity_pct (0-100) and a short +reasoning. The Python code then applies SPEECH_SIMILARITY_THRESHOLD to filter. +""" + +speech_history_similarity_pt = """ +Você é um analista de reclamações Anatel da TIM. + +Sua tarefa é avaliar a similaridade entre a RECLAMAÇÃO ATUAL do cliente e cada +item da LISTA DE RECLAMAÇÕES HISTÓRICAS deste mesmo cliente. + +A similaridade deve ser baseada na natureza/tema do problema, comparando a +descrição completa da reclamação atual com os campos categóricos de cada item +histórico: +- motivo_reclamacao +- submotivo_reclamacao +- causa_raiz +- reclamacao_resumo + +Para cada item do histórico, atribua um score de similaridade de 0 a 100 e uma +justificativa curta (1 frase, máximo 80 caracteres) explicando por que é (ou não é) +relacionado ao tema atual. + +[RECLAMAÇÃO ATUAL]: +{current_complaint_description} + +[LISTA DE RECLAMAÇÕES HISTÓRICAS (JSON)]: +{history_json} + +INSTRUÇÕES DE SAÍDA: +1. Retorne APENAS um JSON array, sem markdown e sem texto antes ou depois. +2. Inclua um item para CADA reclamação histórica recebida (mesmo as com score baixo). +3. O filtro final por threshold é aplicado pelo código — você não precisa filtrar. +4. Se a lista histórica vier vazia, retorne []. + +FORMATO ESTRITO: +[ + {{ + "protocolo": "", + "similaridade_pct": , + "reasoning": "" + }} +] +""" diff --git a/src/agent/local_prompts/ticket_reclassification.py b/src/agent/local_prompts/ticket_reclassification.py new file mode 100644 index 0000000..822aaca --- /dev/null +++ b/src/agent/local_prompts/ticket_reclassification.py @@ -0,0 +1,44 @@ +ticket_reclassification_pt = """ +Você é um especialista em triagem e classificação de reclamações da TIM Brasil. +Sua tarefa é analisar se a classificação atual (Modalidade e Motivo) é a que melhor representa o problema central relatado pelo cliente ou se uma reclassificação é necessária. + +## SUA TAREFA + +1. Analise a 'description' do cliente. +2. Compare com a 'modality' e 'motive' atuais. +3. Se a categoria atual descreve com precisão o problema central, sua decisão deve ser `tratamento`. +4. Se a categoria atual for genérica, incorreta ou houver outra muito mais específica na lista `available_categories`, sua decisão deve ser `reclassificar`. +5. Em caso de `reclassificar`, você deve obrigatoriamente escolher o par (new_modality e new_motive) que melhor se encaixa nos `available_categories`. + +## FORMATO DE ENTRADA + +{ + "service": "Serviço do produto", + "modality": "Modalidade atual", + "motive": "Motivo atual", + "description": "Relato do cliente", + "available_categories": [ + {"modality": "...", "motive": "..."}, + ... + ] +} + +## FORMATO DE SAÍDA + +Retorne APENAS um JSON válido + +{ + "decision": "tratamento" | "reclassificar", + "reasoning": "Breve explicação do porquê desta decisão", + "new_modality": "Nova modalidade se reclassificar, senão null", + "new_motive": "Novo motivo se reclassificar, senão null" +} + +## REGRAS CRÍTICAS + +- **Limite de caracteres**: Retorne reasoning em no máximo 400 caracteres. +- **Viés Conservador**: Se a categoria atual não for perfeita, prefira `reclassificar` para a opção mais específica disponível. +- **Fidelidade**: Se `decision` for `reclassificar`, `new_modality` e `new_motive` DEVEM existir na lista `available_categories`. +- **Linguagem**: O `reasoning` deve ser em português brasileiro, conciso e direto. +- **Limpeza**: Não invente categorias. Se nenhuma for perfeita, escolha a mais próxima semanticamente. +""" diff --git a/src/agent/local_prompts/tim_complaint_analysis.py b/src/agent/local_prompts/tim_complaint_analysis.py new file mode 100644 index 0000000..94d8528 --- /dev/null +++ b/src/agent/local_prompts/tim_complaint_analysis.py @@ -0,0 +1,49 @@ +tim_complaint_analysis_pt = """ +Você é um especialista em análise e triagem de reclamações de telecomunicações, atuando especificamente na identificação de demandas direcionadas à operadora TIM Brasil. + +## SUA TAREFA + +Sua única responsabilidade é analisar os dados da solicitação (como o relato do cliente, motivo, modalidade e dados de enriquecimento) e se perguntar: "A reclamação é direcionada à TIM?" + +Para tomar sua decisão, leve em consideração os seguintes pontos: +1. O texto do cliente (`description`) cita a TIM explicitamente ou reclama de produtos/serviços e falhas cuja responsabilidade seria da provedora? +2. O relato pode até mencionar outras empresas, mas se a queixa principal afetar contas, sinais ou serviços prestados pela TIM, deve ser confirmada. +3. Se a reclamação for inequivocamente direcionada a outra instituição ou prestadora de serviços, com menção explícita e identificável a essa outra empresa/operadora, a resposta deve ser "não". +4. Caso o texto não seja claro o suficiente para identificar a operadora alvo da reclamação — inclusive quando não há menção explícita nem à TIM nem a outra operadora — a resposta deve ser "inconclusivo". + +## FORMATO DE ENTRADA + +Você receberá um JSON englobando as seguintes informações do contexto: + +{ + "description": "Texto contendo o relato completo da reclamação do cliente", + "motive": "Classificação do motivo do contato", + "modality": "Classificação da modalidade da reclamação", + "service": "Serviço relacionado", + "imdb_enrichment_status_code": "Status HTTP do enriquecimento de dados via IMDB (ex: 200, 204). Pode vir 'Não disponível' quando o MSISDN não foi fornecido.", + "imdb_enrichment_status_type": "Status do usuário retornado pelo enriquecimento IMDB (ex: ativo, cancelado, bloqueado, inativo, suspenso). Virá apenas se o status_code foi 200." +} + +## FORMATO DE SAÍDA + +Retorne APENAS um objeto JSON válido, sem marcações markdown ou preâmbulos. + +{ + "is_tim_complaint": "sim" | "não" | "inconclusivo", + "operator": "Operadora", + "reasoning": "Breve explicação do porquê desta decisão, referenciando o que foi encontrado no relato ou nos dados complementares.", + "complaint_summary": "Resumo curto e direto do problema reclamado pelo consumidor (1 frase, ~80 a 120 caracteres).", + "complaint_quote": "Trecho EXATO e literal extraído do campo `description` que evidencia o motivo da reclamação." +} + +## REGRAS CRÍTICAS + +- O JSON deve conter estritamente as propriedades `is_tim_complaint`, `operator`, `reasoning`, `complaint_summary` e `complaint_quote`. +- O valor previsto em `is_tim_complaint` deve ser exatamente uma das três opções: "sim", "não" ou "inconclusivo". +- O valor da propriedade `operator` deve ser o nome da operadora alvo da reclamação. Se a operadora for inconclusiva, retornar `null`. +- O valor da propriedade `reasoning` deve estar em português brasileiro, ser claro, conciso e referenciar elementos concretos do relato. +- A ausência de dados de enriquecimento (quando `imdb_enrichment_status_code` ou `imdb_enrichment_status_type` vierem como "Não disponível") NÃO deve ser interpretada como evidência contra a TIM nem a favor de qualquer outra operadora. Decida com base exclusivamente no conteúdo textual da reclamação. +- Se a decisão for "não", o campo `operator` é OBRIGATÓRIO e deve identificar explicitamente a outra operadora/instituição citada no relato. Se não for possível identificar com base no texto qual é a outra operadora, a decisão correta é "inconclusivo" (com `operator` igual a `null`), nunca "não". +- O valor da propriedade `complaint_summary` deve ser uma única frase em português brasileiro, descrevendo objetivamente o que o consumidor reclama (ex.: "ligações de oferta indesejadas", "cobrança indevida na fatura"). Se a descrição não permitir identificar o objeto da reclamação, retornar exatamente a string "ausente na reclamação". Nunca retornar `null`. +- O valor da propriedade `complaint_quote` deve ser um trecho LITERAL e VERBATIM do campo `description` (sem parafrasear, sem reordenar, sem reescrever), que sustente a identificação da operadora alvo ou do problema reclamado. Se a `description` não contiver um trecho aproveitável (ex.: vazia, ininteligível, sem menção ao problema), retornar exatamente a string "ausente na reclamação". Nunca retornar `null`. +""" \ No newline at end of file diff --git a/src/agent/local_prompts/treatment_decision_prompt.py b/src/agent/local_prompts/treatment_decision_prompt.py new file mode 100644 index 0000000..2500197 --- /dev/null +++ b/src/agent/local_prompts/treatment_decision_prompt.py @@ -0,0 +1,58 @@ +treatment_decision_prompt = """ +Você é um orquestrador de chamados, e precisa delegar o chamado para o agente mais adequado ou para que um humano o responda. + +## Agentes disponíveis: +- Agente de Vendas: cuida de tudo relacionado a planos e ofertas da TIM. Ele esclarece dúvidas dos clientes, sugere opções melhores e tenta converter conversas em vendas, mas sem abordagem ativa. Também pode ativar planos, fazer migrações (pré, controle e pós) e realizar cancelamentos quando solicitado. +- Agente de Contas: cuida de questionamentos sobre valores cobrados (mesmo que não estejam em atraso), cobranças indevidas, fatura, serviços adicionais (VAS), aumento de preço ou divergência de valores. Ele atende casos como: cobranças de serviços extras (VAS), dúvidas sobre valores proporcionais após mudança de plano, fim de descontos, e diferenças entre o valor cobrado e o combinado. +- Agente de Cobranças: negocia dívidas de clientes TIM, apresentando e explicando débitos, propondo formas de pagamento, registrando promessas, fechando acordos e tratando objeções como falta de pagamento, discordâncias ou óbito do titular. + +## Regras de classificação: +1. Se o cliente tem dúvidas sobre planos, ofertas, ou deseja ativar/migrar/cancelar um plano, classifique para o Agente de Vendas. +2. Se o cliente tem questões sobre cobranças, valores do plano, ou discrepâncias entre o valor cobrado e o combinado, classifique para o Agente de Contas. +3. Se o cliente tem uma dívida com a TIM e precisa negociar, classifique para o Agente de Cobranças. +4. Se o chamado não se encaixa claramente em nenhuma das categorias acima, ou se a descrição do problema for vaga ou ambígua, classifique para "Humano". + +## Instruções para classificação: +- Analise cuidadosamente a descrição do problema do cliente e verifique os detalhes fornecidos. +- A classificação deve ser baseada exclusivamente nas informações fornecidas pelo cliente. Não faça suposições ou inferências além do que foi explicitamente mencionado. +- Se houver múltiplas intenções conflitantes (ex: cobrança + mudança de plano), classifique como SMART HUMAN. +- Se houver múltiplas intenções relacionadas ao mesmo domínio (ex: negociar e pagar dívida), escolha o agente mais apropriado. +- Se houver baixa confiança na classificação, classifique como SMART HUMAN. +- Quando classificar para humano, utilize o termo "SMART HUMAN" para indicar que o chamado deve ser tratado por um atendente humano. +- Junto com a classificação, forneça uma breve justificativa explicando os motivos que levaram àquela decisão, destacando os pontos-chave da descrição do problema que influenciaram a classificação. + +## Ordem de prioridade para decisão: +1. Situações ambíguas, múltiplas intenções ou baixa confiança → SMART HUMAN +2. Dívida, atraso ou negociação de pagamento → Agente de Cobranças +3. Problemas com valores cobrados, fatura ou divergências → Agente de Contas +4. Planos, ofertas, ativação, migração ou cancelamento → Agente de Vendas + +## Resposta JSON (STRICT): +{{ + "classification": { + "motive": "...", + "description": "...", + "category": "VENDAS | CONTAS | COBRANCAS | UNKNOWN" + }, + "decision": { + "agent_type": "IA | SMART_HUMAN", + "target_agent": "vendas_agent | contas_agent | cobrancas_agent | null" + }, + "reasoning": "..." +}} + +## Regras críticas: +- **Fidelidade**: A classificação deve ser baseada exclusivamente nas informações fornecidas. Você pode fazer inferências simples baseadas em linguagem comum do cliente (ex: “pagar conta” pode indicar cobrança), mas evite suposições complexas ou não suportadas pelo contexto. +- **Linguagem**: A justificativa deve ser em português brasileiro, concisa, profissional e direta. +- **Limpeza**: Não invente categorias no JSON de saída. + +## Exemplos de classificação: + +- "quero pagar minha conta atrasada" → Agente de Cobranças +- "minha fatura veio com valor errado" → Agente de Contas +- "quais planos vocês têm?" → Agente de Vendas +- "quero cancelar meu plano" → Agente de Vendas +- "não reconheço essa cobrança" → Agente de Contas +- "não tenho dinheiro pra pagar" → Agente de Cobranças +- "quero suporte técnico" → SMART HUMAN +""" \ No newline at end of file diff --git a/src/agent/logic/__pycache__/validation.cpython-313.pyc b/src/agent/logic/__pycache__/validation.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b9525d70599969d59a87ae2651a06573149344b4 GIT binary patch literal 10319 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 literal 0 HcmV?d00001 diff --git a/src/agent/logic/validation.py b/src/agent/logic/validation.py new file mode 100644 index 0000000..ffdcd85 --- /dev/null +++ b/src/agent/logic/validation.py @@ -0,0 +1,226 @@ +import logging +from src.api.schemas.anatel_schemas import ( + TicketRequestEvent, ValidationResult, ReasonCode, + InputChannel, CaseType +) + +logger = logging.getLogger("agent.validation") + +def validate_required_fields(ticket: TicketRequestEvent) -> ValidationResult: + """ + Validates the required fields of a TicketRequestEvent for processing by the agent. + If fields are missing, it returns a validation result populated with the required + error format for the origin system. + """ + from src.api.schemas.anatel_schemas import ERROR_CODE_MAPPING + + error_messages = [] + validated_fields: list[str] = [] + missing_fields: list[str] = [] + + def mark_valid(loc: tuple): + validated_fields.append(".".join(loc)) + + def mark_missing(loc: tuple, fallback_text: str): + mapping_result = ERROR_CODE_MAPPING.get(loc) + if mapping_result: + code, text = mapping_result + else: + code, text = ReasonCode.FIELD_ERROR, fallback_text + error_messages.append({"code": code.value, "text": text}) + missing_fields.append(".".join(loc)) + + try: + # 1. Validate Customer + customer = ticket.customer + if not customer: + error_messages.append({"code": ReasonCode.FIELD_ERROR.value, "text": "Customer data is required"}) + missing_fields.append("customer") + else: + if customer.cpfCnpj and customer.cpfCnpj.strip(): + mark_valid(("customer", "cpfCnpj")) + else: + mark_missing(("customer", "cpfCnpj"), "Customer cpfCnpj is required") + + if customer.name and customer.name.strip(): + mark_valid(("customer", "name")) + else: + mark_missing(("customer", "name"), "Customer name is required") + + if customer.odcCustomer is not None: + mark_valid(("customer", "odcCustomer")) + else: + mark_missing(("customer", "odcCustomer"), "Customer odcCustomer is required") + + if customer.contumazCustomer is not None: + mark_valid(("customer", "contumazCustomer")) + else: + mark_missing(("customer", "contumazCustomer"), "Customer contumazCustomer is required") + + # Validate Address fields + address = customer.address + if not address: + error_messages.append({"code": ReasonCode.FIELD_ERROR.value, "text": "Address data is required"}) + missing_fields.append("customer.address") + else: + if address.cep and address.cep.strip(): + mark_valid(("customer", "address", "cep")) + else: + mark_missing(("customer", "address", "cep"), "Address CEP is required") + + if address.street and address.street.strip(): + mark_valid(("customer", "address", "street")) + else: + mark_missing(("customer", "address", "street"), "Address street is required") + + if address.neighborhood and address.neighborhood.strip(): + mark_valid(("customer", "address", "neighborhood")) + else: + mark_missing(("customer", "address", "neighborhood"), "Address neighborhood is required") + + if address.city and address.city.strip(): + mark_valid(("customer", "address", "city")) + else: + mark_missing(("customer", "address", "city"), "Address city is required") + + if address.state and address.state.strip(): + mark_valid(("customer", "address", "state")) + else: + mark_missing(("customer", "address", "state"), "Address state (UF) is required") + + # Validate Subscriber fields + subscriber = customer.subscriber + if not subscriber: + error_messages.append({"code": ReasonCode.FIELD_ERROR.value, "text": "Subscriber data is required"}) + missing_fields.append("customer.subscriber") + else: + if subscriber.cpfCnpj and subscriber.cpfCnpj.strip(): + mark_valid(("customer", "subscriber", "cpfCnpj")) + else: + mark_missing(("customer", "subscriber", "cpfCnpj"), "Subscriber cpfCnpj is required") + + if subscriber.subscriberName and subscriber.subscriberName.strip(): + mark_valid(("customer", "subscriber", "subscriberName")) + else: + mark_missing(("customer", "subscriber", "subscriberName"), "Subscriber name is required") + + # 2. Validate Complaint fields + complaint = ticket.complaint + if not complaint: + error_messages.append({"code": ReasonCode.FIELD_ERROR.value, "text": "Complaint data is required"}) + missing_fields.append("complaint") + else: + if complaint.complaintProtocol and complaint.complaintProtocol.strip(): + mark_valid(("complaint", "complaintProtocol")) + else: + mark_missing(("complaint", "complaintProtocol"), "Complaint protocol is required") + + if complaint.motive and complaint.motive.strip(): + mark_valid(("complaint", "motive")) + else: + mark_missing(("complaint", "motive"), "Complaint motive is required") + + if complaint.modality and complaint.modality.strip(): + mark_valid(("complaint", "modality")) + else: + mark_missing(("complaint", "modality"), "Complaint modality is required") + + if complaint.openedAt: + mark_valid(("complaint", "openedAt")) + else: + mark_missing(("complaint", "openedAt"), "Complaint openedAt date is required") + + if complaint.description and complaint.description.strip(): + mark_valid(("complaint", "description")) + else: + mark_missing(("complaint", "description"), "Description is required") + + if complaint.actionType and complaint.actionType.strip(): + mark_valid(("complaint", "actionType")) + else: + mark_missing(("complaint", "actionType"), "ActionType is required") + + if not (complaint.inputChannel and complaint.inputChannel.strip()): + mark_missing(("complaint", "inputChannel"), "InputChannel is required") + elif complaint.inputChannel != InputChannel.ANATEL: + error_messages.append({ + "code": ReasonCode.INVALID_VALUE.value, + "text": "Invalid value for field inputChannel or it's not supported yet" + }) + missing_fields.append("complaint.inputChannel") + else: + mark_valid(("complaint", "inputChannel")) + + if complaint.service and complaint.service.strip(): + mark_valid(("complaint", "service")) + else: + mark_missing(("complaint", "service"), "Service is required") + + if complaint.firstService and complaint.firstService.strip(): + mark_valid(("complaint", "firstService")) + else: + mark_missing(("complaint", "firstService"), "FirstService is required") + + # 3. Validate Origin + origin = ticket.origin + if not origin: + error_messages.append({"code": ReasonCode.FIELD_ERROR.value, "text": "Origin data is required"}) + missing_fields.append("origin") + else: + if origin.sourceSystem and origin.sourceSystem.strip(): + mark_valid(("origin", "sourceSystem")) + else: + mark_missing(("origin", "sourceSystem"), "SourceSystem is required") + + if not origin.submittedBy: + error_messages.append({"code": ReasonCode.FIELD_ERROR.value, "text": "SubmittedBy data is required"}) + missing_fields.append("origin.submittedBy") + else: + if origin.submittedBy.userId and origin.submittedBy.userId.strip(): + mark_valid(("origin", "submittedBy", "userId")) + else: + mark_missing(("origin", "submittedBy", "userId"), "UserId is required") + + if origin.submittedBy.name and origin.submittedBy.name.strip(): + mark_valid(("origin", "submittedBy", "name")) + else: + mark_missing(("origin", "submittedBy", "name"), "SubmittedBy name is required") + + # 4. Validate Top-level fields + if not (ticket.caseType and ticket.caseType.strip()): + mark_missing(("caseType",), "CaseType is required") + elif ticket.caseType != CaseType.ANATEL: + error_messages.append({ + "code": ReasonCode.INVALID_VALUE.value, + "text": "Invalid value for field caseType or it's not supported yet" + }) + missing_fields.append("caseType") + else: + mark_valid(("caseType",)) + + except Exception as e: + logger.exception(f"Unexpected error during validation logic: {e}") + raise + + if not error_messages: + return ValidationResult( + is_valid=True, + validated_fields=validated_fields, + missing_fields=missing_fields, + ) + + # Format the error response as requested in Swagger + error_response = { + "title": "validation error", + "status": 400, + "detail": { + "messages": error_messages + } + } + + return ValidationResult( + is_valid=False, + error_response=error_response, + validated_fields=validated_fields, + missing_fields=missing_fields, + ) diff --git a/src/agent/nodes/__init__.py b/src/agent/nodes/__init__.py new file mode 100644 index 0000000..b184922 --- /dev/null +++ b/src/agent/nodes/__init__.py @@ -0,0 +1,40 @@ +from . import ( + different_complaint_operator_node, + siebel_sr_opening_node, + fetch_ticket_node, + validation_node, + imdb_enrichment_node, + identity_verification_node, + canceling_analysis_node, + speech_enrichment_node, + knowledge_base_enrichment_node, + treatment_decision_node, + cache_check_node, + tim_complaint_analysis_node, + tim_complaint_node, + undefined_complaint_operator_node, + reclassification_analysis_node, + treatment_decision_node, + bypass_rules_node, +) + +__all__ = [ + "tool_node", + "siebel_sr_opening_node", + "fetch_ticket_node", + "validation_node", + "imdb_enrichment_node", + "identity_verification_node", + "canceling_analysis_node", + "speech_enrichment_node", + "knowledge_base_enrichment_node", + "treatment_decision_node", + "cache_check_node", + "tim_complaint_analysis_node", + "tim_complaint_node", + "different_complaint_operator_node", + "undefined_complaint_operator_node", + "reclassification_analysis_node", + "treatment_decision_node", + "bypass_rules_node", +] diff --git a/src/agent/nodes/__pycache__/__init__.cpython-313.pyc b/src/agent/nodes/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c18e7e09ccef0cf8f0149b206756323aac192f4 GIT binary patch literal 914 zcmbtSyN(kv6!lEr?+0ijKw9l0A#EB)G*k$Q5+e{O4V~rKcM@-A?C9FNtCV~MAHgq8 zOGSr>(6-o_g(Og7$w$}sagKd`x4mAQ`1S17?-kz}+mGJ(OlD*jy&*Kh zh{hPx3RY+ptF(qSTE{wVV1qWXNn6;WZEVvHc4!y7w1+*~$37k4VCNs=@Qfs67JrCv zw5Uw0-y%;x9Tn$Nr70k2$Arn(8CTviYe4P{GHoOjCsx56GG;LZw9<>xKT5%gC38xw zz?H7SHP4jfP8nS~?yFqR8E8~unFo;_<3lCAt=xuP1FBSsgNljZT0j<5xaQf$Dq9wv z*foIH`?f#iZwAl6kP>klAwa;OAl|lL3>9}Y)W3t`F&<_d0T7Y_h zMu293R)8=}*a;BGuos{oU=W}ZU>IN&V3#uX);lk87yTdlJ#!!}8jSHQW9$pr`Xebs zl53a9G3B8)DD`tdB=3J~y>Sw|Kh|zv7oRVgrlUNjGT@pHP z6ULNQjWPYs;-%wL%5 B5EcLc literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/__init__.cpython-39.pyc b/src/agent/nodes/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0b402faf9f4ccf94e46f72cafb24bae1d290dc2b GIT binary patch literal 883 zcmbtSyN(kv6rIWYolF$%N+7MYbZHo|1rTU#&k;l-QkCeGq`E^o zx=XscM|!$X`g%YHdPs(PL`Hf{#(F{~dP=5xMrL|W=6XREdP$Z|d_`7ivQI?kYf3WF z{g`ZfKhj9Q&FWhrivk>&K)KZi#bjLB12`6}qs9uTue^jEDC!+1j5PZ;o)r*yNrU7S zgf_H%U`h%Wq&00|BemF3Fi!GPqe#Q}M2fhU;Y1(6$wKn88`qo}4hmJ6Vd~_iZ!cZ> z13=XKo9w1C_NYMYf$o@xfAZJC0Sk?|051KXx2-tiCD1r|)k-`KQlGWqQh$A!Yzn!( zHv$T2K%8B>?B0z7u9)|aWnR#_Y5a#e+no;a40ZPk+H$vRG7i?N=3%ZI54q@;#Ptyd z2t$Ms!WaQv)sVRvuI2~}1T>UeBCHTl&t(xlY}fVg?Ela|G9zg{q?9Q|>9@pfPzm8@ znSb)&e9oQpK{8`Y>q}X&Bg@~}qfwR#pR+ysqi{^a(YlISF6mv4ro_(d7_V#*tpd4s hygi)bANZV7X=I@E`QcQLUTG_06JMjfJ(m7{^9xg>36KB) literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/bypass_rules_node.cpython-313.pyc b/src/agent/nodes/__pycache__/bypass_rules_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2735373c6ca473d3679a363395d55a891eb982e2 GIT binary patch literal 6236 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 literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/cache_check_node.cpython-313.pyc b/src/agent/nodes/__pycache__/cache_check_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..89f414348ed2dccbe1582b217bfffcf94d9ffc0f GIT binary patch literal 4826 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 KLjP*u+W!EUQKUTp literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/fetch_ticket_node.cpython-313.pyc b/src/agent/nodes/__pycache__/fetch_ticket_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..65e7ad8356df0335b821e93c4c3b73242529e54d GIT binary patch literal 2594 zcma)8&2!UM5Pz~HTb3PvK|&Ks(S$%T1v`PJd<8mb;+WWAO5~i98I6#h6M-XzCz&=r zrR|~RM0;SGBL@!ct(W%P8Tto=nwfYsWMHP9&T#2CO)kB4pKRgqHB-&#y?t+Y-|pM} z?MmA{JtBf5?-cyzEBE{H_*=uk0Ih?1yB3&oy7jKm6Y5-%i3LPGP*%%E?&I}f7y)F{(l zlRfjI9G+)oai0B1h49Sh931RV*S^u%j&+=7KbCst&G7j-lPe zjxAM)wIoekku&0Rs#{72TycB>n~v;&oEx^WqhLZTl4jg+o~X-1$~hj!Kb?OD4b58X(R_0;ndsAJ+{P0!QL#Ip+3Cw$gIryIwmvM8||yjdpJT z@|kb(H!+NSAI!jP;Ei`m`X`(0EPJ$LP``$DN4(efqdUtlAp6#c_jRRx%J=5=h|l%= zG7z~v^4?vUd&4<$?^$kWw})@SM4x{%n$IC5pwi#)o<7Z1nDoB-l9)5?8o_p_Y9tCu z$*`qm*aJ9AhABCVSkf%h!Jj)BQ7kV4mzIHaTdf)(PIbEyl4|M_VA|AGq8s%N(*t=% ztoP5(ABJOq!8+EZ6B5Cy zz9KmmxM^vi=5o`esjE=nL29W6YiDH{yDXGi8Ol}g%dH+7&yRb@p2|V3LJ3N`C3zO` zIZ0bomsH)dC0fTSTo}Tj5>@ggTz)*CE0xnhl7ci5ic3PF?y`=$;D$7{TBXhhEn+Mf zCZTw7;{@NTVH^5MJA(E1OV&%Vu2?mQ7=-y1K{q;iTOZ7YQK3PQg!BupiFueZ8=uP7)}Jt9UA~zLFGqTrJ?WR?!7|^BR z5V)0N=vG%IYZI-lm!;^nv#&ugv%KOaU)rU`Kn{Z^)Is|UH0S4N745_jANjiYMX@Oy z+F=>~@J8Z$A&zZuW9!^lGuiij|9AasV?U%C$>XbqKe_NX>1OIo zGtsxjbA3UocVKPy{&Yjkv_i1a5>RU28ux9_KVgnPxD!RZNEPy+(Cm|%@pqbg4m4B! zTRj3O$2_C=(~nO~{aS0BxY7_ytvE1R3IC9!z#nYwMTy?KD|c4b2JX)^;zw66HDi5u zt9PpF`;RwbCsy-+MN&_M*xl%z=z8zP$HK=?V(+ch9vB-(rXC%cYQ*vzLVjJyKLOj( zWHUO@j9z<^9N0*XJW7sih53ZoLVQ?!5t8-9$YbtgGm==nyfwif{=(KU3SVOWwdoiC z%Ttp_nV*@-A?}w?&H(u^!%QCG9%erR`nMwtjOkuCq$oOkAQd-65eU7)-4B_|cp1;M z`$#+c9!+S=^RVRhzA8qcZnshU?wfeZBCalDVteRzQ&gnp7*#uCUAHj-01^06Lhb>| z(5$6p)yeEaoG(M6@9yg=8=4LHt5yN07V#>fT@$oA+%Oc&<#u5SiedtM6@?t2SD+~S z5pBbW$Pf&!Ft%jrwJN?yPQsG@7uaPOS}embTY&(>Z3~ErKSdWd(FdF8>?TTYqU=+o yJwn>EaAG4o@OyY*?Mx#)v>JTIL|TDkO!EG@7J})gW7~AvW>FxzMfb@F9Q+?H=8}8> literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/identity_verification_node.cpython-313.pyc b/src/agent/nodes/__pycache__/identity_verification_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9998b4bd33209e84d5b0812577fb4641808ce1f0 GIT binary patch literal 8332 zcmbU`TWlN0cC&oHDT$@+*4Trew*ER&?x8z4V$~N$XI%?CjF9 zg}6{|i&n<{u-!I@ie9A7ZGk92ff#5%{At|$I5$6J=|aR-NsZg!0{NqB-#*ZO^vo=m zlx#U}JCJ5(=bSln=FFKh=NvxwdR++0Tf^f#0GspDU z5#qc?@34jQHKS`5yo>YCwQx1?@15H{*II#_tU%|mjjN?tdnlH7t`+9h(GhmbNPv#C z&DCHxo%-N($*TuZpsiP_dXv3#!(j z!&EHr>Jp)C02;4m#dNAXNt6~6DIqB;FqJT)!^C7F!{1EHdp}c>vcYz6*Lps=a_l zmJFb?yAhIWcUsa|YAf-nCWKwASuNYUB&ofr-YG81UcmA-A*m|8JkgfKJm!~#4`lp)LQaakSNY`o^8CCA zf=m}!)^ol8wSXCOK`T?z=T=3{GJJ&|O9QtDD%j2KS+?)Aj=YJ{= z0RF6TKl7<`KkQhuT$4p9Y{GRgtl8+!mGXvhGa2)3UlW7K5LrMWLR~>cyXT`@MSq~^ z_1}5-_PhDu(a$;y!J`H5(UsFhckP{%cV}~!rssf*G)FS4sPdA4HK(|gx|$%DDM^HD z6mrq2K23oBe2n5y>m`O}$oNZzV+zYfnGA*Ey`W6y%9jnH zOM8btK!^v<7tAqcN3Jaf2b^{265vMA)}8J=$7akiTFLSf7nW^YbX2&oFF3-^<)6+j zia6Dif$M#f{a{g$*rdFaN%Nw_ODa2h;WV2PLHia2g`LOp602mdDoHF}6)-DfHaayn z!d_88xA`Km2*mXFU(Rh4zz$z^PTockYHy&#t!=!o$VDo~Q15%);tB8`Vbu zAr@{Y%T`r=Vm_(aHN`7eB*w%oFqxKt@&&7?2Yknv4zvv$6!$gQ0fIeaxETf-w1#cv z%tH$Z%ypbdo|ahUT7nU`EFT=ss-SQxv1>v~SH3F?^7RqS34HnrD=MrcOFhC;M!gx) zoE6Av90GEn1l`bRE_l@>&?9&pHOp05PHSdG#hMi=F#}pdG)1#0KyjP(+Lhp8bs2-j zOeL<1d;-WPuHpni#7AIGFZ?UyJy}6d-GMvDZy#TC7u+2yV@13BqYsPz;HJIyZpSYy z8}{9K`))9Ft8RV7ykT$2+go7d&hqW$pMSW9^ZvG+qixgeziavVP|gy1^=QcfQTs*? z2yoZoVAy#vEx---ZlgCFW~khuA(BXKbc&4`46l!Z{4H&7wFszj}$GxWnkrP#53$(p!Ui z6miZ)P@C(_$L49P2eGe{%Ku@X673A1NE4zx(;}jvy^u6W7c&a=T&- z^jad;IC#r?fs<|mnkH^{#56$65|nYqkS@EGMk}|6a?c)uH7TvuS7^2VC@lzF^#WYC z5w0)&9oJ6UII%CZaC>9!cmVIw{Ux11DxSZjxkv%}wesaI3Acr&n)MoRFvG zl9}5#Xp-P=fo8j^XHO*xPtX?K0KMb(pJ%u($-{+#$L{ie4p3gh2E}c>?zqm*t2@vc zX8MH#O~2EI;^gt8#g6zZ2ua>}Pk8{v$=gLs%xg$xrq39wwgyb{#eASw2V-8)E3@R^ zqJ@X5*hVkr=fYgipovA7N?e5QtYRZB`C|SzTL{Ip2P~K59rkvV+j(XloiOh~^9+r* zdY8v9V4>&SxhXa!r~+ORB~bxCfi(gmHn}Jy-ydSj@hSTu%aH(;Jxc;q_|hZP2soXB zLW16en4(UD=BUK#ek}ZcR!|@+mnBuWp+bnB5rpJof|vO8O;}9PTuDBw@CnR|%gg5J z-r?o;nXDv1FkR(iYhVW6N=lg4{1+h(g9rwe9Ve-P<*q3an1ER?t;muvhZQB(;E6XHQ7!D9C;9OQV_9BsTeLlkYTSYZ1yC~2(#cX z>CRe;XTf1&IkNbf^B38X8M>4pC3!fgMPLOyYJgWyB$7>15{j_2bsG2`5Wm7Uu%Hm0 z-$X{I3t0ycuPy*!1p;o!tLWd6?1>-UoJ52M`yg%~4?-a#dwH`_pl}f6wvAI(pd*ys zvS8)3m=wY71Twm|L7*?GG8`%!9b<kH89=MZK93@Nv>L ztNcRPwA?~HQZ55YE696{!9|JavEnG)F~|~twm{$@OJ!CM^=FU5Z2jhyRRhjt5pGIK z9%4sn>WnBRs5&|GF+HXwI@bjnt}$WFtDj;*7mgOtKS}1SDlSL)q#?bB-yz)ARk4io z0}#0{ntMw?p}DH!pVfj}l4*%W(4aIqin^_%`jX2m%c~Vzv+amzqHc%FDV2 zZ1nszCSEEHfoT*(9e1s%f;u)n8sz{jabf2C==k`Uw)<5OHw9_S#M#)?@U+(WDp=2Y zVjo$@Ln`9yV&ddu5>0BXju4$blNg>JK6jama*1-LbT&FVIX;U?2#bkdtU1STBn4fO z$V-K%$e<&Ukl->V5|GBEx!4*!M1VxU@Y@8#Ubgwv(CTRf_m*3SPZOlt@&Q&al2%te z#zfg@;W0v$7!A!qFi9+_F@olzODBk8Yvv_oL8$>0nt#|*>8Xg_p!;w#OYle$rG6>h zhnHaD+wiZb;EIv#UCAag)_0j=sQHu2_b#t}xIR+|^{u*$@8$LWLMXBk>d%MzbBD$r zT`h#78= zDb|FFHEqS3q0Lb1M(A)pboiOm8uXQr)$1$yP^jgT$$OLcUF#!-VDCn7;6eYYqZr&p z26Jrx0~r0uM)1VL{#EDq;gLs23*mS3j(reTw+=q+DzuK~98H_8M;|s8T1Rq@=HL38 zHyhttJzH#y-;RFgZQX1fSUvl+?o{sWcXFp@N{Hc@^9(e#7no;ed(Xm|c z{1eB8k_|Pr6+8QiZT-dWW5w{XVpIDwul=BF)t;-_pLcYX>X0Y+q@lOq?%nj%-VGP` z%oiJbi;Zo?)~;d$`^@cX^}zyxz4r&!!^OkLi){yXEk& zX186=z4w0Zaypk@%I%T|WpNq=A8v%XNMKLUiSBpj5) z3o8nBm#IGW2Uk7+ddq~IXP9U3V}SP}^B*r-kSFvUS>ZNph8lknT;u<;VWTOWZwePe z-Ebpncir{mT8=+_=izA1_x7rpsJZ91=Pq~u@Tvz6rP$t?^R?z3dp7-zYoYbgzZW-- z#Paaxzxa=ii~m(}6UzUstibL288g|({LzSQ!i;|9VxlK4zj6q9QmKSBE^8R-9-p|#mH*<=2-cQ`cJW&Wm}fsb$Y zT4BOBT~^ZXF++b_MuZqZFb3%PCRDFa7gZzY9piCR6;5)6JK|KLs#hcS9q}tssbq?6 z$$ijR2~tH1Rv0DR(^46=!DuDZSQ*t5)csR@M$bnX<`H57{iiGz+%1x*0w+>s7s#(| z)CG@V;sw%I7*CKMHPIv+fQb>tBx9i2=6OhOrNS2aNa8C5kRa7hp$ePz7egQeWVS-2 z4Tf7c*lkz_UpT?fgS+z@qn!c#FUiDts370v3)~TJi)*g?BL(074c~#h??CS0iHEr0 z8(BG1tZDefeb1e1IaH`Qyiqgs(6r${mG_@|%UoIvLXE;B@ez9dKs6* zA&TxOwCn^+EDC9Ow-oA7Xw3vAtBPqQVjNBdfj;l#Wv5Rzedx7@Dn0~S5Q3%C@S~ZG zsY3v+P=%U&RS___&X|Ol+HL|MiF#g*Z24u)z#%RLc(0Hi z^%dyP36_+YR5h!bgV4|0*>E=hgR^<skGp`|&qn+dMJS3=M{9Q_?>s9637-sbtn literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/imdb_enrichment_node.cpython-313.pyc b/src/agent/nodes/__pycache__/imdb_enrichment_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7df63de8863d70d7143d954216015b6d3bb65782 GIT binary patch literal 8163 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

5`UGpepwE=6le)UNt}f+pe$xJN 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 literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/reclassification_analysis_node.cpython-313.pyc b/src/agent/nodes/__pycache__/reclassification_analysis_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d99473931fdfc4f133f6c845b7db713854fe1465 GIT binary patch literal 11958 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 literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/siebel_sr_opening_node.cpython-313.pyc b/src/agent/nodes/__pycache__/siebel_sr_opening_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b37c566f9e5df220659b08f32c93e180a7d69903 GIT binary patch literal 14440 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 H{0jdE79cYS literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/siebel_triplet_classification_node.cpython-313.pyc b/src/agent/nodes/__pycache__/siebel_triplet_classification_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..04733973b1b27ad6f904793a9af3b3393bb1abe5 GIT binary patch literal 6236 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 literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/speech_enrichment_node.cpython-313.pyc b/src/agent/nodes/__pycache__/speech_enrichment_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..051a321aba54b22ea4038c5761bb199d52938b78 GIT binary patch literal 7416 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 literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/tim_complaint_analysis_node.cpython-313.pyc b/src/agent/nodes/__pycache__/tim_complaint_analysis_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..25051744bbe9321cb27426ad9606f6d7ce8cfac1 GIT binary patch literal 10388 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 SgY~Cs=BXa2PgJU+$NvJ?ZtH0P literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/tim_complaint_node.cpython-313.pyc b/src/agent/nodes/__pycache__/tim_complaint_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9fbe0197021fb7814bb7f3aa80216a8b6f0e5b8b GIT binary patch literal 1977 zcmZ`(%}*Og6rZ)%AG;U>CMAJ1%9f-`okTXN;<k~}N`@F}8%*Q@^#jz^usfI~>s>lK zCWRAi4{c9v4^cVhkVAUwvDf?uBt*iR7By0H$&H9!dh44trb(5qv@`F$dGp?z_xsHp z_V(%szAMGi)%6%ce@dnMfgXN`_h7n%Fv3a|F*!%7N+rS~hLoe#XhmgeD67?2CC=iZ z9IGZONtO)dcvY+DOb_KmwWpF|sZdT<dn;*{uJo}!11+Mu(MkB?IEoer3QEVqGK)#9 zEk>}u7`dwAo)NUF7LJ_RU_;5Ko=L5iRfJw_P>*9yI0a3cVq|RvbGimfe?YluwHN~j zGw9fK&WJ@#&$p#}|5D3wZPT&LHL~IQ#0Fc8u7iIzBGR@Ge%xI4*=@pX@#c+Lvo<?t zPR`apD%Nk9^->Yf*326vJW(&-oGXJoSDq2EK-MDSjPe%qtfLc!#rMGYHT;K@U_FNn z==D6RzvvOVA?QDj6e1nPQ~t*%I)=JFuZ*GoyY596j-nw{@1lK_OHMe}?Yw~&65WM) zw2<t|^^iSXtuuGCml2MyL=jq7?nG;`6r5|Jx9j<FWFg&^aRMi=0e=<-jx8G2anHPh zHTYAw7pHTHg~9IXJi=NxM@i{Q2K4MaN}^if-RQEC?b~E}4bU|B3gN~I@obj{hUGWc zT;h0Kir%urn$$K(U@TF`YZ!p20Z`@ii4Hm34TBiqZ_yRswJGbwaw)L`gZl=fmP>-b zS#~VK9pB68LJueye9v@jfkd)NIk5>RqL0zfTQmUL`5vcV@Xg|fvLcxj624H&ae+DG zCi5*yZRzu>WHk|mt-wcM6bXD!^l`!(z-)hwGQxefnJ7*bb7NzL&1>OZpoUE?$97sW z9e2DIVX_~FZvgi>X#%@_!v@W<Zc%O!-Xg9@;Q5J(62>A{oGR7k#1MCyaEPNWn#3dS zM&Jb5gosOiw*sN^4Jbf@E25k<M8YDjEBg`mnbUAQp@vsZ7f~oy#9hD9piHQa2i=zx zWUAacTrW*b7cnkRmM4ng9V*s})1TlnW}0-8v}2NmOwuwNW%3SnvL=pt902u@VHl#B zZ+bj$=Vx$PHg%O+JjfIIJebQ2&VAcww@lx1@=L_J<u3!ARN}Nw8F%2WAN%A3W_9-E z<vS7NU+#sp<<>UDi4NJ!msZ(%C?*Aea36+eXbT;rQ0C-M!#@shU%#K(9k{$zX{QIj zcfNDB^}FM{=?i=5OAphRb}qmFE89(vZ%sW_^>3<QR(JaGV|DI#ePp|EFS|EddN^9z z)hGAV$sKj_shatr@c82F4q1MrHlC{5x7l`Prrkf>9x~c}r`m&O+L_^Y|69-dw4wN4 z{ix^kcHwSzSHFBPAPq>1B@TvAYG7;n`B@a3QvMDyQ0!O53*&Dn53<VmS@pricR<db z5DAGJP*a?SrsTqA=E4WABg3-_j~$Z;h}Zt>A@L77Fu(q)HFJf!K%pR{gUCpi7I)kr z=Pw160W2&o<DUF?!Lb5B%yj`-pM{JNXXL6RD&e_f1kK|Jk~$*H-U)s5n8S&Vvmf(J zXu#Cz4o{P&=>cU-lbwYKA*w&4Ll6-=CqpK$H+{S1(ht}bP~{UI%)oFERTSknl-@^g z@1slm=-NKY?xU;wDE|lg?1|RDr;R+)Mz-JE)y{9lpC~;CkyFaR-PHpG(=VSM%IPqQ MBB|%n&c-0(KfCu9@&Et; literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/treatment_decision_node.cpython-313.pyc b/src/agent/nodes/__pycache__/treatment_decision_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2848823019e3b3d04f74e1d9b07b4d76e5af5a88 GIT binary patch literal 5072 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 D5T)0H literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/undefined_complaint_operator_node.cpython-313.pyc b/src/agent/nodes/__pycache__/undefined_complaint_operator_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a08901dfaad169aff6bb9b43c16f593bec541844 GIT binary patch literal 16519 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 Ug8-~cg7;E-Aox3kpzHg80ouX*@&Et; literal 0 HcmV?d00001 diff --git a/src/agent/nodes/__pycache__/validation_node.cpython-313.pyc b/src/agent/nodes/__pycache__/validation_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..046a7f0f9e097b4402eae4caa4a7c070ac2062b0 GIT binary patch literal 10155 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 literal 0 HcmV?d00001 diff --git a/src/agent/nodes/bypass_rules_node.py b/src/agent/nodes/bypass_rules_node.py new file mode 100644 index 0000000..c74fda8 --- /dev/null +++ b/src/agent/nodes/bypass_rules_node.py @@ -0,0 +1,156 @@ +import logging +import time +from src.agent.state.agent_state import AgentState, update_state_metadata +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.infrastructure.oci.autonomous.cases_manager import has_bypass_eligible_history, CASES_COLLECTION +from src.infrastructure.oci.autonomous.connection import db_manager +from src.utils.decision_helpers import ( + BYPASS_AUDIT_MSG, + apply_treatment_triplet, + build_canceling_decision, + build_forwarding_decision_do_not_forward, + build_reclassification_decision, +) +from src.utils.observer import trace_node +from src.utils.ics_collector import build_ic_payload, build_llm_token_fields, build_noc_db_metadata, build_noc_db_payload +from src.compat.framework_observer import event + +logger = logging.getLogger(__name__) + + +@trace_node +async def evaluate_bypass_rules(state: AgentState) -> AgentState: + """ + Determines whether the canceling/reclassification/forwarding validations should + be skipped for this case. + + Bypass triggers: + 1. complaint.actionType == "reabertura" + 2. Prior case for the same complaintProtocol was closed with one of: + opened_cancelation_sr / opened_reclassification_sr / opened_forwarding_sr + """ + await set_current_step(state, GraphStep.BYPASS_RULES) + session_id = state.get("session_id", "") + + context = state.get("metadata", {}).get("request_context", {}) + complaint = context.get("complaint", {}) or {} + action_type = (complaint.get("actionType") or "").strip().lower() + protocol = complaint.get("complaintProtocol") + + bypass = False + reason = None + prior_transaction_id = None + evaluation_failed = False + + if action_type == "reabertura": + bypass = True + reason = "actionType=reabertura" + elif protocol: + _t0 = time.perf_counter() + try: + prior_transaction_id = await has_bypass_eligible_history(protocol) + event("NOC.003", { + "status": "Database query executed successfully", + "type": "INFO", + **build_noc_db_payload( + state, "NOC.003", + latency_ms=int((time.perf_counter() - _t0) * 1000), + resource_name=CASES_COLLECTION, + ), + }, metadata={"noc": True}) + if prior_transaction_id: + bypass = True + reason = "prior_case_closed_with_bypass_status" + except Exception as e: + evaluation_failed = True + logger.error( + "bypass_rules | evaluation_error | protocol=%s | error=%s", + protocol, e + , exc_info=True) + event("AGA.030", { + "status": f"Insucesso avaliação dos cenários de retorno à Anatel - {type(e).__name__}: {e}", + "type": "FAILURE", + "session_id": session_id, + "tag": "AGA.030", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.030", { + "agentSpecificData.complaintProtocol": protocol, + "resourceName": CASES_COLLECTION, + "errorType": type(e).__name__, + "errorMessage": str(e), + "latencyMs": int((time.perf_counter() - _t0) * 1000), + }), + }) + + state["bypass_treatment_validations"] = bypass + + if bypass: + # Pre-populate all fields that the skipped analysis nodes would have set, + # so siebel_sr_opening and downstream consumers receive a valid payload. + context = dict(state.get("metadata", {}).get("request_context", {})) + context = apply_treatment_triplet(context) + context["canceling_decision"] = build_canceling_decision("continuar", BYPASS_AUDIT_MSG) + context["reclassification_decision"] = build_reclassification_decision("tratamento", BYPASS_AUDIT_MSG) + context["forwarding_decision"] = build_forwarding_decision_do_not_forward(BYPASS_AUDIT_MSG) + state = update_state_metadata(state, request_context=context) + logger.info( + "bypass_rules | active | protocol=%s | reason=%s | prior_transaction_id=%s", + protocol, reason, prior_transaction_id + ) + else: + logger.info( + "bypass_rules | inactive | protocol=%s | action_type=%s | db_connected=%s", + protocol, action_type, (db_manager.db is not None) + ) + + if not evaluation_failed: + if action_type == "reabertura": + id_classification = "reabertura" + elif prior_transaction_id: + id_classification = "retorno_anatel" + else: + id_classification = "nova" + + causa_raiz = ( + state.get("metadata", {}).get("request_context", {}) + .get("speech_analytics", {}).get("causa_raiz", "N/A") + ) + + # Per PDF: AGA.029 fires only when the ID is NOT a reabertura nor a retorno + # Anatel (i.e., id_classification == "nova"). The "reabertura"/"retorno_anatel" + # branches go to bypass treatment and emit AGA.005 instead to register the + # agent decision to continue with the bypassed ticket. + if id_classification == "nova": + event("AGA.029", { + "status": f"ID classificado: {id_classification}", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.029", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.029", { + "agentSpecificData.id": id_classification, + "agentSpecificData.complaintProtocol": protocol or "N/A", + "agentSpecificData.priorTransactionId": prior_transaction_id or "N/A", + "agentSpecificData.bypassActive": bypass, + }), + }) + else: + event("AGA.005", { + "status": f"Agente decidiu continuar com o tratamento do ticket atual (bypass: {id_classification})", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.005", + "call_id": session_id, + "origin": "AGENT", + "step": "bypass_rules", + **build_ic_payload(state, "AGA.005", { + "agentSpecificData.statusCode": id_classification, + "intention": causa_raiz, + **build_llm_token_fields(None), + }), + }) + + return state diff --git a/src/agent/nodes/cache_check_node.py b/src/agent/nodes/cache_check_node.py new file mode 100644 index 0000000..33de1e8 --- /dev/null +++ b/src/agent/nodes/cache_check_node.py @@ -0,0 +1,103 @@ +import logging +import time +from src.agent.state.agent_state import AgentState, update_state_metadata +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.infrastructure.oci.autonomous.memory_manager import get_memory, MEMORY_COLLECTION +from src.utils.observer import trace_node +from src.utils.ics_collector import build_ic_payload, build_noc_db_metadata, build_noc_db_payload +from src.compat.framework_observer import event + +logger = logging.getLogger(__name__) + +@trace_node +async def check_cache_node(state: AgentState) -> AgentState: + """ + Checks the 'memory' collection for existing enrichment data (IMDB + Speech). + If both are found and valid, populates the state context and sets cache_found to True. + """ + await set_current_step(state, GraphStep.CACHE_CHECK) + session_id = state.get("session_id", "") + + context = state.get("metadata", {}).get("request_context", {}) + complaint = context.get("complaint", {}) + protocol = complaint.get("complaintProtocol") or context.get("transactionId") + + if not protocol: + logger.warning( + "Protocol not found in context. Skipping cache check.") + state["cache_found"] = False + return state + + logger.info( + f"Checking cache for protocol: {protocol}") + + try: + _t0 = time.perf_counter() + cached_data = await get_memory(protocol) + event("NOC.003", { + "status": "Cache lookup completed successfully", + "type": "INFO", + **build_noc_db_payload( + state, "NOC.003", + latency_ms=int((time.perf_counter() - _t0) * 1000), + resource_name=MEMORY_COLLECTION, + ), + }, metadata={"noc": True}) + + if cached_data: + imdb_data = cached_data.get("imdb_access_data") + speech_data = cached_data.get("speech_analytics") + relevant_documents = cached_data.get("relevant_documents") + + # Invalida o cache se o CPF/CNPJ do cliente mudou desde a entrada cacheada. + # imdb_access_data.cpf_cnpj é eco do customer.cpfCnpj original (ver + # imdb_enrichment_node), então uma divergência indica que a chamada atual + # tem cliente diferente para o mesmo protocolo — não podemos confiar no + # cache (afeta identity_verification e demais nodes downstream). + current_cpf = (context.get("customer") or {}).get("cpfCnpj") + cached_cpf = (imdb_data or {}).get("cpf_cnpj") + if imdb_data and current_cpf and cached_cpf and current_cpf != cached_cpf: + logger.warning( + f"Cache invalidated for protocol {protocol}: customer cpfCnpj changed " + f"(cached={cached_cpf}, current={current_cpf}). Treating as cache miss.") + state["cache_found"] = False + return state + + if imdb_data: + context["imdb_access_data"] = imdb_data + if speech_data: + context["speech_analytics"] = speech_data + if relevant_documents: + context["relevant_documents"] = relevant_documents + + state = update_state_metadata(state, request_context=context) + + if imdb_data and speech_data and relevant_documents: + logger.info( + f"Full cache HIT for protocol: {protocol}. Enrichment nodes will be skipped.") + state["cache_found"] = True + else: + logger.info( + f"Partial cache found for protocol: {protocol}. Enrichment nodes will be executed but will use available cached data.") + state["cache_found"] = False + else: + logger.info( + f"Cache MISS for protocol: {protocol}.") + state["cache_found"] = False + + except Exception as e: + logger.error( + f"Error during cache check: {e}", exc_info=True) + + state["cache_found"] = False + + return state + +def should_skip_enrichment(state: AgentState) -> str: + """ + Router condition: if cache was found (full), skip enrichment nodes. + """ + if state.get("cache_found") is True: + return "skip" + return "continue" diff --git a/src/agent/nodes/canceling_analysis_node.py b/src/agent/nodes/canceling_analysis_node.py new file mode 100644 index 0000000..e7ce8c0 --- /dev/null +++ b/src/agent/nodes/canceling_analysis_node.py @@ -0,0 +1,416 @@ +import time +import json +import logging +from src.agent.state.agent_state import AgentState, update_state_metadata, set_error +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.providers.llm_provider import classification_llm, chat_llm_with_usage, LLM_ENDPOINT +from src.utils.observer import trace_node, score_current_trace +from src.compat.framework_observer import event +from src.agent.local_prompts.canceling_analysis import canceling_analysis_pt +from src.agent.local_prompts.duplicate_analysis import duplicate_analysis_pt +from src.core.prompt_manager import get_prompt +from src.utils.text import truncate_text +from src.utils.decision_helpers import ( + CANCEL_REASON_DUPLICATE, + apply_cancellation_triplet, + build_canceling_decision, +) +from src.utils.ics_collector import build_ic_payload, build_llm_token_fields, build_noc_llm_metadata, build_noc_metadata + +logger = logging.getLogger(__name__) + +class CancelingAnalysisError(Exception): + pass + + +class DuplicateAnalysisError(Exception): + pass + +async def _analyze_cancellation(context: dict, session_id: str = "") -> tuple[dict, dict]: + """Integrity check for safety violations; returns (parsed_response, llm_meta).""" + llm = classification_llm + llm_meta: dict = {"content": None, "prompt": None, "model": str(llm.eligibleModel_name), "latency_ms": 0} + + payload = { + "service": context.get("complaint", {}).get("service"), + "first_service": context.get("complaint", {}).get("first_service"), + "modality": context.get("complaint", {}).get("modality"), + "motive": context.get("complaint", {}).get("motive"), + "description": context.get("complaint", {}).get("description") + } + logger.info(f"Canceling analysis payload: {json.dumps(payload, ensure_ascii=False, indent=4)}") + + # Get prompt from Langfuse with local fallback + prompt = get_prompt("ticket_canceling_analysis_pt", canceling_analysis_pt) + message = f"{prompt}\n\n[Complaint Payload]:\n{json.dumps(payload, ensure_ascii=False)}" + llm_meta["prompt"] = message + + try: + _t0 = time.perf_counter() + llm_resp = chat_llm_with_usage(llm, message, expect_json=True) + content = llm_resp.content + llm_meta["content"] = content + llm_meta["latency_ms"] = int(llm_resp.latency_ms) + llm_meta["model"] = llm_resp.model + llm_meta["prompt_tokens"] = llm_resp.prompt_tokens + llm_meta["completion_tokens"] = llm_resp.completion_tokens + logger.debug(f"Canceling analysis LLM response: {content}") + + parsed_response = llm_resp.parsed_json or {} + decision = parsed_response.get("decision", "").lower() + decision_valid = decision in ["cancelar", "continuar"] + + score_current_trace(name="cancelation_decision_valid", value=1.0 if decision_valid else 0.0) + + if not decision_valid: + msg = f"Canceling analysis LLM returned unknown decision: {decision}." + logger.error(msg) + err = CancelingAnalysisError(msg) + err.llm_meta = llm_meta + raise err + + return parsed_response, llm_meta + + except CancelingAnalysisError: + raise + except Exception as e: + error_type = str(type(e).__name__) + msg = f"{error_type} calling LLM for canceling analysis: {e}" + logger.error(msg, exc_info=True) + llm_meta["latency_ms"] = int((time.perf_counter() - _t0) * 1000) + err = CancelingAnalysisError(msg) + err.llm_meta = llm_meta + raise err + +async def _analyze_duplicate(context: dict, session_id: str = "") -> tuple[dict, dict]: + """Duplicate-detection LLM call against complaintHistory; returns (parsed_response, llm_meta).""" + llm = classification_llm + llm_meta: dict = {"content": None, "prompt": None} + + complaint = context.get("complaint", {}) or {} + customer = context.get("customer", {}) or {} + history = context.get("complaintHistory", []) or [] + + payload = { + "current_complaint": { + "complaintProtocol": complaint.get("complaintProtocol"), + "service": complaint.get("service"), + "firstService": complaint.get("firstService"), + "modality": complaint.get("modality"), + "motive": complaint.get("motive"), + "description": complaint.get("description"), + "openedAt": complaint.get("openedAt"), + "customer": { + "cpfCnpj": customer.get("cpfCnpj"), + "msisdn": customer.get("msisdn"), + "phones": customer.get("phones") or [], + }, + }, + "complaint_history": [ + { + "complaintProtocol": item.get("complaintProtocol"), + "status": item.get("status"), + "actionType": item.get("actionType"), + "providerProtocol": item.get("providerProtocol"), + "openedAt": item.get("openedAt"), + "inputChannel": item.get("inputChannel"), + "service": item.get("service"), + "firstService": item.get("firstService"), + "modality": item.get("modality"), + "motive": item.get("motive"), + "description": item.get("description"), + "cpfCnpj": item.get("cpfCnpj"), + "msisdn": item.get("msisdn"), + "phones": item.get("phones") or [], + } + for item in history + ], + } + logger.info( + f"Duplicate analysis payload | history_items={len(payload['complaint_history'])} | " + f"protocol={complaint.get('complaintProtocol')}" + ) + + # Get prompt from Langfuse with local fallback + prompt = get_prompt("ticket_duplicate_analysis_pt", duplicate_analysis_pt) + message = f"{prompt}\n\n[Duplicate Payload]:\n{json.dumps(payload, ensure_ascii=False, default=str)}" + llm_meta["prompt"] = message + + try: + llm_resp = chat_llm_with_usage(llm, message, expect_json=True) + content = llm_resp.content + llm_meta["content"] = content + llm_meta["latency_ms"] = int(llm_resp.latency_ms) + llm_meta["model"] = llm_resp.model + llm_meta["prompt_tokens"] = llm_resp.prompt_tokens + llm_meta["completion_tokens"] = llm_resp.completion_tokens + logger.debug(f"Duplicate analysis LLM response: {content}") + + parsed_response = llm_resp.parsed_json or {} + decision = (parsed_response.get("decision") or "").lower() + + decision_valid = decision in ["duplicada", "nova"] + + score_current_trace(name="duplicate_decision_valid", value=1.0 if decision_valid else 0.0) + + if not decision_valid: + msg = f"Duplicate analysis LLM returned unknown decision: {decision}." + logger.error(msg) + err = DuplicateAnalysisError(msg) + err.llm_meta = llm_meta + raise err + + return parsed_response, llm_meta + + except DuplicateAnalysisError: + raise + except Exception as e: + error_type = str(type(e).__name__) + msg = f"{error_type} calling LLM for duplicate analysis: {e}" + logger.error(msg) + err = DuplicateAnalysisError(msg) + err.llm_meta = llm_meta + raise err + + +@trace_node +async def perform_canceling_analysis(state: AgentState) -> AgentState: + """Runs integrity + duplicate checks and routes to cancellation or next step.""" + try: + session_id = state.get("session_id", "") + causa_raiz = state.get("metadata", {}).get("request_context", {}).get("speech_analytics", {}).get("causa_raiz", "N/A") + logger.info("Running canceling analysis node") + await set_current_step(state, GraphStep.CANCELING_ANALYSIS) + + context = state.get("metadata", {}).get("request_context", {}) + + canceling_result, llm_meta = await _analyze_cancellation(context, session_id=session_id) + + if not canceling_result or "error" in canceling_result: + return set_error(state, "LLMError", + canceling_result.get("error", "Failed to get canceling analysis result from LLM"), + step=GraphStep.CANCELING_ANALYSIS_FAILED, + ) + + decision = canceling_result.get("decision", "").lower() + reasoning = canceling_result.get("reasoning") + cancel_reason = canceling_result.get("cancel_reason") + + logger.info(f"Canceling analysis completed successfully with Decision: {decision}. Model Reasoning: {reasoning}\n") + + truncated_reasoning = truncate_text(reasoning, 200) if reasoning else None + truncated_cancel_reason = truncate_text(cancel_reason, 200) if cancel_reason else None + + updated_context = { + **context, + "cancel_reason": truncated_cancel_reason, + "canceling_reasoning": truncated_reasoning, + } + + if decision == "cancelar": + updated_context["canceling_decision"] = build_canceling_decision( + "cancelar", truncated_reasoning, cancel_reason=truncated_cancel_reason + ) + + event("AGA.002", { + "status": "Agente decidiu cancelar o ticket atual", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.002", + "call_id": session_id, + "origin": "LLM", + **build_ic_payload(state, "AGA.002", { + "agentSpecificData.statusCode": decision, + "intention": causa_raiz, + **build_llm_token_fields(llm_meta), + }), + }, metadata={"noc": True}) + + if cancel_reason == "Propaganda": + event("AGA.031", { + "status": "Agente decidiu cancelar o ticket atual por Propaganda", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.031", + "call_id": session_id, + "origin": "LLM", + **build_ic_payload(state, "AGA.031", { + "agentSpecificData.statusCode": decision, + "intention": causa_raiz, + **build_llm_token_fields(llm_meta), + }), + }, metadata={"noc": True}) + + updated_context = apply_cancellation_triplet(updated_context) + + await set_current_step(state, GraphStep.CANCELING_ANALYSIS_CANCEL_TICKET) + else: + # Integrity check passed; duplicate detection may still flip to cancel. + history = context.get("complaintHistory") or [] + has_history = bool(history) + _dup_meta = None # only set when the duplicate LLM actually runs + if not has_history: + # Empty history is deterministically "nova"; skip the LLM call. + logger.info( + "Skipping duplicate analysis LLM — complaintHistory is empty | " + f"protocol={(context.get('complaint') or {}).get('complaintProtocol')}" + ) + dup_result = {"decision": "nova"} + else: + try: + dup_result, _dup_meta = await _analyze_duplicate(context, session_id=session_id) + except DuplicateAnalysisError as dup_err: + # Fail-open: duplicate-detection failure must not cancel a legitimate ticket. + logger.error(f"Duplicate analysis failed (fail-open): {dup_err}") + dup_result = { + "decision": "nova", + "reasoning": ( + "Análise automática de duplicidade indisponível por falha " + "no processamento da decisão. Recomenda-se verificação manual " + "do histórico de reclamações do cliente." + ), + } + + dup_decision = (dup_result.get("decision") or "").lower() + duplicate_match = dup_result.get("duplicate_match") + dup_reasoning = dup_result.get("reasoning") + dup_case_response = dup_result.get("case_response") + + if dup_decision == "duplicada": + logger.info( + f"Duplicate detected — flipping decision to cancel | " + f"matched_protocol={(duplicate_match or {}).get('matched_protocol')} | " + f"similarity={(duplicate_match or {}).get('similarity_percent')}" + ) + # 400-char cap mirrors the prompt's own limit. + truncated_dup_reasoning = truncate_text(dup_reasoning, 400) if dup_reasoning else None + # External text consumed only by the Duplicidade case_response builder. + truncated_dup_case_response = ( + truncate_text(dup_case_response, 200) if dup_case_response else None + ) + updated_context = { + **updated_context, + "cancel_reason": CANCEL_REASON_DUPLICATE, + "canceling_reasoning": truncated_dup_reasoning, + "canceling_case_response_text": truncated_dup_case_response, + "canceling_decision": build_canceling_decision( + "cancelar", + truncated_dup_reasoning, + cancel_reason=CANCEL_REASON_DUPLICATE, + duplicate_match=duplicate_match, + ), + } + updated_context = apply_cancellation_triplet(updated_context) + + aga002_extra = { + "agentSpecificData.statusCode": "cancelar", + "intention": causa_raiz, + } + if isinstance(duplicate_match, dict): + aga002_extra["agentSpecificData.matchedProtocol"] = duplicate_match.get("matched_protocol") + aga002_extra["agentSpecificData.similarityPercent"] = duplicate_match.get("similarity_percent") + + event("AGA.002", { + "status": "Agent decided to cancel current ticket (duplicate)", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.002", + "call_id": session_id, + "origin": "LLM", + **build_ic_payload(state, "AGA.002", { + **aga002_extra, + **build_llm_token_fields(_dup_meta), + }), + }, metadata={"noc": True}) + + event("AGA.033", { + "status": "Agente decidiu cancelar o ticket atual por reclamação duplicada", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.033", + "call_id": session_id, + "origin": "LLM", + **build_ic_payload(state, "AGA.033", { + "agentSpecificData.statusCode": "cancelar", + "intention": causa_raiz, + **build_llm_token_fields(_dup_meta), + }), + }, metadata={"noc": True}) + + await set_current_step(state, GraphStep.CANCELING_ANALYSIS_CANCEL_TICKET) + else: + # Both checks said continuar/nova — combine reasonings when history was actually scored. + if has_history and dup_reasoning: + truncated_dup_reasoning = truncate_text(dup_reasoning, 400) + combined_reasoning = ( + f"{truncated_reasoning} {truncated_dup_reasoning}" + if truncated_reasoning else truncated_dup_reasoning + ) + else: + combined_reasoning = truncated_reasoning + updated_context = { + **updated_context, + "canceling_reasoning": combined_reasoning, + "canceling_decision": build_canceling_decision( + "continuar", + combined_reasoning, + ), + } + + event("AGA.005", { + "status": "Agente decidiu continuar com o tratamento do ticket atual", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.005", + "call_id": session_id, + "origin": "LLM", + "step": "canceling_analysis", + **build_ic_payload(state, "AGA.005", { + "agentSpecificData.statusCode": decision, + "intention": causa_raiz, + # Use the last LLM call's tokens (_dup_meta when history was + # actually checked, else fall back to the cancellation call). + **build_llm_token_fields(_dup_meta or llm_meta), + }), + }) + + await set_current_step(state, GraphStep.PROCEED_GRAPH) + + state = update_state_metadata(state, request_context=updated_context) + return state + except CancelingAnalysisError as e: + _llm_meta = getattr(e, "llm_meta", {}) + event( + "NOC.004", + { + "status": f"LLM error during cancellation analysis: {e}", + "type": "FAILURE", + **build_noc_llm_metadata( + state, "NOC.004", + latency_ms=_llm_meta.get("latency_ms") or 0, + llm_endpoint=LLM_ENDPOINT, + model_name=_llm_meta.get("model") or str(classification_llm.eligibleModel_name), + ), + }, + metadata={"noc": True} + ) + + event( + "NOC.009", + { + "status": f"Transfer to human: {e}", + "type": "FAILURE", + **build_noc_metadata(state, "NOC.009"), + }, + metadata={"noc": True} + ) + + state["final_response"] = str(e) + return set_error(state, "CancelingAnalysisError", str(e), step=GraphStep.CANCELING_ANALYSIS_FAILED) + except Exception as e: + error_type = str(type(e).__name__) + msg = f"{error_type} calling LLM for canceling analysis: {e}" + state["final_response"] = msg + logger.error(msg, exc_info=True) + return set_error(state, error_type, msg, step=GraphStep.CANCELING_ANALYSIS_FAILED) diff --git a/src/agent/nodes/different_complaint_operator_node.py b/src/agent/nodes/different_complaint_operator_node.py new file mode 100644 index 0000000..172eeff --- /dev/null +++ b/src/agent/nodes/different_complaint_operator_node.py @@ -0,0 +1,277 @@ +import logging +from src.agent.state.agent_state import AgentState +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.components.clients.abrt_client import AbrtClient +from src.components.clients.exceptions.abrt_exceptions import AbrtClientError +from src.core.config import settings + +from src.utils.forwarding_helpers import ( + forward_complaint, + do_not_forward, + is_tim, + FORWARD_REASON_ABRT_OPERATOR, + FORWARD_REASON_IMDB_ERROR, + FORWARD_REASON_ABRT_ERROR, + FORWARD_REASON_ABRT_INVALID, + FORWARD_REASON_ABRT_NOT_FOUND, + FORWARD_REASON_CONTEXT_OPERATOR, + DO_NOT_FORWARD_REASON_IMDB_TIM, + DO_NOT_FORWARD_REASON_ABRT_TIM, +) +from src.utils.observer import trace_node, trace_tool +from src.utils.ics_collector import build_ic_payload, build_noc_api_metadata, build_noc_metadata +from src.compat.framework_observer import event + +logger = logging.getLogger(__name__) + + +IMDB_TIM_ACTIVE_STATUSES = {"Ativo", "Suspenso", "Bloqueado"} +IMDB_TIM_INACTIVE_STATUSES = {"Cancelado", "Inativo"} + + +@trace_node +async def perform_different_operator(state: AgentState) -> AgentState: + """Decides forwarding based on MSISDN, IMDB statusType, and ABRT lookup.""" + try: + session_id = state.get("session_id", "") + logger.info("Running different operator node") + await set_current_step(state, GraphStep.FORWARDING_ANALYSIS) + + context = state.get("metadata", {}).get("request_context", {}) + + customer = context.get("customer", {}) + cpf = customer.get("cpfCnpj") + msisdn = customer.get("msisdn") + + # Step 1: missing MSISDN — fall back to context operator. + if not msisdn: + logger.warning( "MSISDN not provided. Falling back to context operator.", extra={"customer": customer} ) + result = forward_complaint( + state, + context, + reason=FORWARD_REASON_CONTEXT_OPERATOR, + operator=_get_context_operator(context) + ) + return result + + # Step 2: inspect prior IMDB call — 204 or inactive statusType falls through to ABRT. + imdb_result = context.get("imdb_access_data") + + if imdb_result is None: + logger.error("IMDB result not found in context. Falling back to context operator.") + result = forward_complaint( + state, + context, + reason=FORWARD_REASON_IMDB_ERROR, + operator=_get_context_operator(context) + ) + return result + + + if imdb_result.get("status_code") == 204: + logger.info("IMDB status 204 (number not found in TIM). Proceeding to ABRT request.") + + if imdb_result.get("status_code") == 200: + status_type = imdb_result.get("statusType") + + logger.info(f"IMDB status 200 | contract statusType: {status_type!r}") + + if status_type in IMDB_TIM_ACTIVE_STATUSES: + result = do_not_forward(state, context, reason=DO_NOT_FORWARD_REASON_IMDB_TIM, target_operator="TIM") + + event("AGA.005", { + "status": f"IMDB identificou cliente TIM ({status_type}) — seguir via LLM", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.005", + "call_id": session_id, + "origin": "AGENT", + "step": "different_complaint_operator", + **build_ic_payload(state, "AGA.005"), + }) + return result + + if status_type in IMDB_TIM_INACTIVE_STATUSES: + logger.info(f"IMDB statusType '{status_type}'. Proceeding to ABRT request.") + + else: + logger.warning(f"Unexpected IMDB statusType: {status_type!r}. Proceeding to ABRT as fallback.") + + event("NOC.002", { + "status": "Invalid API response", + "type": "WARNING", + **build_noc_api_metadata( + state, "NOC.002", + retry_count=imdb_result.get("retry_count", 0), + latency_ms=imdb_result.get("latency_ms", 0), + api_url=imdb_result.get("url", settings.PMID_API_HOST), + status_code=imdb_result.get("status_code"), + ), + }, metadata={"noc": True}) + + # Step 3: query ABRT for the current operator of the number. + abrt_result = await _fetch_abrt(cpf, msisdn, state=state, session_id=session_id) + + if abrt_result is None: + logger.warning("ABRT API call failed. Falling back to context operator.", extra={"msisdn": msisdn}) + + result = forward_complaint(state, context, reason=FORWARD_REASON_ABRT_ERROR, operator=_get_context_operator(context)) + return result + + status = abrt_result.get("status") + company = abrt_result.get("company") + active = abrt_result.get("active") + + # ABRT did not find the number. + if status == "2": + logger.info("ABRT did not find the customer. Falling back to context operator.", extra={"status": status, "msisdn": msisdn}) + result = forward_complaint(state, context, reason=FORWARD_REASON_ABRT_NOT_FOUND, operator=_get_context_operator(context)) + return result + + # Invalid or insufficient ABRT response. + if status not in ["0", "1"] or not company: + logger.warning( + "ABRT returned insufficient or invalid data.", + extra={ + "status": status, + "company": company, + "active": active, + "msisdn": msisdn + } + ) + + event("NOC.002", { + "status": "Invalid API response", + "type": "WARNING", + **build_noc_api_metadata( + state, "NOC.002", + retry_count=abrt_result.get("retry_count", 0), + latency_ms=abrt_result.get("_latency_ms", 0), + api_url=abrt_result.get("_api_url", settings.ABRT_API_BASE_URL), + status_code=200, + ), + }, metadata={"noc": True}) + + result = forward_complaint( + state, + context, + reason=FORWARD_REASON_ABRT_INVALID, + operator=_get_context_operator(context) + ) + return result + + company_normalized = company.strip().upper() + + if is_tim(company_normalized): + logger.info("ABRT identified operator as TIM. Complaint belongs to TIM.", extra={"status": status, "company": company, "active": active, "msisdn": msisdn}) + result = do_not_forward(state, context, reason=DO_NOT_FORWARD_REASON_ABRT_TIM, target_operator="TIM") + + event("AGA.005", { + "status": "ABRT identificou cliente TIM — seguir via LLM", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.005", + "call_id": session_id, + "origin": "AGENT", + "step": "different_complaint_operator", + **build_ic_payload(state, "AGA.005"), + }) + return result + + logger.info( + "ABRT identified another operator. Forwarding complaint.", + extra={ + "status": status, + "company": company, + "active": active, + "msisdn": msisdn + } + ) + + result = forward_complaint( + state, + context, + reason=FORWARD_REASON_ABRT_OPERATOR, + operator=company_normalized + ) + return result + + except Exception as e: + msg = f"{type(e).__name__}: {e}" + await set_current_step(state, GraphStep.FORWARDING_ANALYSIS_FAILED) + + event("NOC.009", { + "status": f"Forwarding analysis failed: {e}", + "type": "FAILURE", + **build_noc_metadata(state, "NOC.009"), + }, metadata={"noc": True}) + + logger.error(msg, exc_info=True) + raise + + +@trace_tool +async def _fetch_abrt( + cpf: str, + msisdn: str, + state: AgentState | None = None, + session_id: str = "", +) -> dict | None: + try: + client = AbrtClient() + response, http_meta = await client.get_abrt_data_with_retry( + social_sec_no=cpf, + msisdn=msisdn, + max_retries=2, + ) + + api_fields = { + "apiUrl": http_meta["url"], + "apiStatusCode": http_meta["status_code"], + "apiResponsePayload": http_meta["response_text"], + "latencyMs": http_meta["latency_ms"], + } + + result = response.model_dump() + result["_latency_ms"] = http_meta["latency_ms"] + result["_api_url"] = http_meta["url"] + result["retry_count"] = http_meta.get("retry_count", 0) + + event("AGA.037", { + "status": "API reencaminhamento: Consulta à ABRT", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.037", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.037", api_fields), + }) + + return result + + except AbrtClientError as exc: + api_fields = { + "apiUrl": getattr(exc, "url", None) or settings.ABRT_API_BASE_URL or "N/A", + "apiStatusCode": getattr(exc, "status_code", None) if getattr(exc, "status_code", None) is not None else "N/A", + "apiResponsePayload": getattr(exc, "response_text", None) or str(exc), + "latencyMs": getattr(exc, "latency_ms", None) if getattr(exc, "latency_ms", None) is not None else "N/A", + } + + event("AGA.038", { + "status": f"API reencaminhamento: Erro Consulta à ABRT - {type(exc).__name__}: {exc}", + "type": "FAILURE", + "session_id": session_id, + "tag": "AGA.038", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.038", api_fields), + }) + + logger.error(f"Failed to fetch ABRT data: {exc}", exc_info=True) + return None + + +def _get_context_operator(context: dict) -> str | None: + """Operator identified by tim_complaint_analysis_node (upper-cased).""" + return context.get("complaint_context_operator") diff --git a/src/agent/nodes/emulator/__init__.py b/src/agent/nodes/emulator/__init__.py new file mode 100644 index 0000000..41cfbe4 --- /dev/null +++ b/src/agent/nodes/emulator/__init__.py @@ -0,0 +1,27 @@ +from . 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, +) + +__all__ = [ + "start_response_emulation_node", + "fetch_case_node", + "validate_actions_node", + "router_node", + "retrieve_templates_node", + "retrieve_history_node", + "generate_response_node", + "validate_response_node", + "persist_draft_node", + "approve_draft_node", + "close_case_node", +] diff --git a/src/agent/nodes/emulator/__pycache__/__init__.cpython-313.pyc b/src/agent/nodes/emulator/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2d5a440674ad719e704e66b69bf5dbbe0aecb828 GIT binary patch literal 656 zcmY+B&5qMB5XbGLZQ7<4+C{ruK&rU)f+fxfu}6$Rpgw?QoXM_tlQ<f?MSJ8ScnDr8 zS5Dl39`?#Op}S=czj!|UA7^I2T+SKU%ZumNHeu{XbPh&-Z~Hg&`NlSE!{0H<k;?=V znPMtOIFe%=%Lz_ohMAn=RL*cF=Qx)+<|`)HCi%p1aha}1KRL;)^X^o)ExH}30(BWw z;|k~=naX)kMiYiW3Bg<)1<o&_0n`B$f^S`OC_HTez4hT(?F=EH1!{3c8Hcxn=9Bwy zfV!<n%MXbh-8KLY&NCl0hKDXtZ>iL}W=NmybY+Y94sBxepTzln_`m0^X$th$IF49E z#Bt*+Vj3}tSVm+KX~c-|VZH2*+(Do(?(6!yy-Kgw_vq!LRF^5Gt4gWwY)>xhDp5B< z6yp8n?dl`CYcRnJO#=<8L|wxdhd0U@D?V#;<I2(+T7_$NfWa2-p+<N#!#xp~<i#)> zhe9da*r3#l_O_e8s$H?I;59y`=5gtMMfjC+&hL^1Pwv<OzvJo8(?y<q&G3YZ{sLFg By`TU9 literal 0 HcmV?d00001 diff --git a/src/agent/nodes/emulator/__pycache__/_rag_query.cpython-313.pyc b/src/agent/nodes/emulator/__pycache__/_rag_query.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..18ee524d6daba9b5155d7bfa87d71ed45e7ac217 GIT binary patch literal 3864 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 KhQ23_VfjBH4?Q~o literal 0 HcmV?d00001 diff --git a/src/agent/nodes/emulator/__pycache__/approve_draft_node.cpython-313.pyc b/src/agent/nodes/emulator/__pycache__/approve_draft_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7341903731460f2b50aec00c4676e4cea767566b GIT binary patch literal 3185 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 BF>(L^ literal 0 HcmV?d00001 diff --git a/src/agent/nodes/emulator/__pycache__/close_case_node.cpython-313.pyc b/src/agent/nodes/emulator/__pycache__/close_case_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f3624259a6e459a0f5ee128f17d9b87e3eacbaf6 GIT binary patch literal 13212 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 Oejiz(=^<^Rv;I$>xfyl< literal 0 HcmV?d00001 diff --git a/src/agent/nodes/emulator/__pycache__/fetch_case_node.cpython-313.pyc b/src/agent/nodes/emulator/__pycache__/fetch_case_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..12378fe62bb52ef87df44a925b9715f7a5871454 GIT binary patch literal 4966 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 literal 0 HcmV?d00001 diff --git a/src/agent/nodes/emulator/__pycache__/generate_response_node.cpython-313.pyc b/src/agent/nodes/emulator/__pycache__/generate_response_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..31a0a0ace2f88170362bdb61ad4f746635609fff GIT binary patch literal 20773 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 P3IN>J_x^%z>>U1I0H4EU literal 0 HcmV?d00001 diff --git a/src/agent/nodes/emulator/__pycache__/persist_draft_node.cpython-313.pyc b/src/agent/nodes/emulator/__pycache__/persist_draft_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d29a928d4dff1910b5f3ad1685d275222668bd46 GIT binary patch literal 4317 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 PE3{j2f_$0Enf3nz8`n)C literal 0 HcmV?d00001 diff --git a/src/agent/nodes/emulator/__pycache__/retrieve_history_node.cpython-313.pyc b/src/agent/nodes/emulator/__pycache__/retrieve_history_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff0b035666d3f2f8622c3bafe58504bf97669563 GIT binary patch literal 3746 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 literal 0 HcmV?d00001 diff --git a/src/agent/nodes/emulator/__pycache__/retrieve_templates_node.cpython-313.pyc b/src/agent/nodes/emulator/__pycache__/retrieve_templates_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bc9454d42d04882a444daff28f2c75b62b804ec1 GIT binary patch literal 2233 zcmZ`)&2Jk;6rcU_$HsAt+mPb)V{j9MTV#utP+F3PL~_&EZJK)H3gJkbjXid@+3cFx zb=njTp&TeDE+F+7Zg4<?TaRtv4@lagpj9FeABS>E<VqmHfj8@H92FR8=Y7tbw{L!L zrrFskBN(SflGj%xgnnUyKS1_I^9vxKAq7#Sa0;L2C<j!?^ErVEnCZm4n3JfK3($Zk zOZi|fL_;1Q$cJ+>l|4R~@5n`H#N$Kx&Rmp6JwBZ8%Ef35qiL>?+}(8_Nuufa0M}B` z1Jf}@o|cr3Y3Vgli6qc<abVY%9<1@H&c^Q`j$a+e4q2!hnnP?{7#YKqI8-N#1nZ__ z;h6>EXcf)T((|+Gp5shfmajGlUBbGJXB)cV;MpZSqf)J=zRUD!rmVE(8T_(Vc67_c zj_w$+(@9)~EgfQ#imaLT4MLf&Y_UvWW4vHhG(&fmG7FZYFOpOmU(j@;L1C#z33e=l zP!0TkUj7tA`pyzIblZ^);@H@kBe+Jj`W&7oq>gKrZr0NB6ZZ2HDbDSBCbtyt$|E&m zI*J2DaJw6I_+O&hOj7;dhjlTVIBK~;DY$}_)Is-LcA)|PVbL+RpVCqu+^dsUfU=>( z3UCfV00(lhftMYsm5FLv72-<XR&D?)-=JoB_W-@K3c~y$e9haSJ&rKItF&h?q7v_U z!Bh}^u1yDat>XZ_IRT+X?lWORia?YSi|dUd@&RLVM^P!{>kA&-eW^v0sK}KfzOE4U zxi(e!v;4EWnS3ekdlX*qxi(dTrCwhu@_sf&ZU8c71G4RDaR|N5D}l3o9}1y~3VfLt zQ7Puzf=EjtWD5Is>-KGJst83v5f$kyG-Nw&5oHjIZ1cyUR;Dl_^zG6rS4Bg@F;wNw z2vsf>x^<D^UI!R5l<gg~*EyP5!8Tw4jPNxKfTC{xZwE~P?j)~ST*<NO>in%EQ+^|| zKJ-ku3aBTQbGYB0VhAEP%c^cxE#M>j8}imG`=z}euHg5fry8a+)X!Ki<WN6NMQH#^ zLKzC&cp*Dg7|&kIs#Dp^lb1%OvI=FTrwj{jz$O|k&(Saw4rMPF!Qo1w?W#^)nN;&` zID4~9>a4-tfML~Ygu1~S8Z`m!lwFUm$g*|?gP19icChdaK5lrgg02AnW-}CH818$S z1=Gn?GMANcJV~wVr0m!kjdiJ7rP>0yVbOWjD(jh9tvqj4t2#7O*)kUib@YnW_Q}|^ z+*+4my=G^~Zo_8$d#-(}dEJ<<FS+r5+vpL<jN!@t3Z^G$89fc5j^4WikCUf2k{8yK z7aqxD8{*iSIQDC#_ipx4B)Od3=7ZAEW+J_j7<iZ%_#xi28Bc7)Pp`*M??^(g{09=c z<vZc0F(h?-l>Z>V7CZl)aDV*U=-OoQv3PB>1Fv+icMPwI!#ll5y2$N>!2eI!H`3ii zJVXvfKZ$-EUF&)Mo^Zc%C%PWWZ+7*q6z|2?y9U1x4gR$)f!E*mBxHR1=;$!F!i`Ge z=Uu>k^U`P-J?P;^gW`ijBI6IA0QG8+1Aa9uj}8i}2ab-O5msO27=KD))-w|02L<3$ z-EL4-D^^)m-5@)0IBEAd6g%x5Qrata+Ansx{StWCa(mmJ-sRU?)w**j13?Ql{m;6w zYge5kMjhS@)sOCh***GSzWba!uqT)kdu1#Wpk$|8ce9;_hpUD6u9gEO;AK|zS_}Ab zrfoR7VW+KGn^1rmWf!Cyt`X-_3-2LSH368aN}p%>GrW9>nlK}J7$!F~2ya@$Am`~x z5ZNVezXQ{@z;WCTFL7csiny-d(D09Fd<%_kp}{Tm<`(MPLaCp^v5j!zVK{O3jYr|W i<-m3T@sVvl#=XU@<hBu`U!QAwv?+jT$1~9r5dI&?uv?%2 literal 0 HcmV?d00001 diff --git a/src/agent/nodes/emulator/__pycache__/router_node.cpython-313.pyc b/src/agent/nodes/emulator/__pycache__/router_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d18f8eeefff9618cd80a80490aefb86ef2fcc749 GIT binary patch literal 2974 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 literal 0 HcmV?d00001 diff --git a/src/agent/nodes/emulator/__pycache__/start_response_emulation_node.cpython-313.pyc b/src/agent/nodes/emulator/__pycache__/start_response_emulation_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eb783d72a4572c10def608fd145ec7b2265a2715 GIT binary patch literal 5449 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 literal 0 HcmV?d00001 diff --git a/src/agent/nodes/emulator/__pycache__/validate_actions_node.cpython-313.pyc b/src/agent/nodes/emulator/__pycache__/validate_actions_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dcf7590b708373d17d77a1e971cabf003a704d6a GIT binary patch literal 7238 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 Uhm#5CyNd|A<(A))jxzCo0R2CrQ~&?~ literal 0 HcmV?d00001 diff --git a/src/agent/nodes/emulator/__pycache__/validate_response_node.cpython-313.pyc b/src/agent/nodes/emulator/__pycache__/validate_response_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a9ec408080846acf99644d54e39890bcd145fe0 GIT binary patch literal 7685 zcmai3Yit`=cD_Rn$>E!nNWCpjBsno9nUZBY>zCq{t%oJqlEV>gHPV(Ekt2yVHN)N+ z+L0P#ncYPb7mGSx#IR5-unMr~dKW0*UqyZ<Xq`>_iz-!+nA)woH3Fo+<duMA(Fc0& z42O~!+Iu11d*?pRoO9;hbG~!5?RGm5l)izs>;GGa(0`H#qu5KG?LG>j`-npraTI47 zr!b|pCTxP%JZ>H{WAhk|X_m~><CZZiwvO4bO?$SCGh=paA9G-b_G}$@j=8W)>)XcN zV;=0$`pmd@%!hqi-#+ditHCu|-!Wc07Qg`(%~Fw0gSFkZ4b9e_0RBk9^_&woa4y`) zxp5QcnQi90vn|JI&ew#l)A&GXq!mW|+Q>n!X4VdC$Vja=!g2xF71VaVHrqDaYyrAP zd)!2xj$64pZEgE(ptRo(`|HWr3Bm>x+_7j1HDvw?7~>S2i7Oe*r`Vf(Dw*Jwq$IM^ z0;?<u?4lqFm{)`Ziv>9?iL$_Uu?vEbnCIg+*rlYbNO*<i(`i9WgdL8fVRjF>_>vI6 zA+v|mSW=|8lw#9BL=cs3_LhVbY+TBaz9_NFJid{TZiylGcYpCSc5YcvcwmDM@A5Mj zcJzhWNs*mXR?<R077WJbx=U}D*||SAc<e5-*bRIy0+#|75;0!6J;y4NgJsz{;3+Q1 za#CCj8{))D;>@wK#4cn&B?8&4YXEd2vrGI<0cKJu;9XRNcNGx%>aNZNj3q3y35lJU zoMPuw5~xU1BIeUe?C-K4Yfn;|XjH=Naz<8ISxEvpSrEva!w>+tgje583b)R%l9*ay z<;?sMS>ct8%<^J_RfLoPS|X|kCW-n4Q2*ED%T9z$Dg)XRl;pCYGNfLWL;*(qlhLcA zgOicbf!N^C@Ids^)ZU>hliZbokx5wR7yy|S4&ElH!AzRaijj8<G2_F8DAgwmN-Uni z7?#4UkcKh;&~he4L>nOjbNV0y3l&ha3=15I`Pecq@{0l{PYUMaLQIqr!q;bk^W+ko z#*l5*)Y-XeYHwdP9buu*uA1lO=2U7%rGB8&MCqy(3-EEoc<ELVsgVtSX83JSL3JO^ zl;>tlFrbwPZS;2AHn0d$)K48Q4JBYT+-lQ_(k|oXcA$`XV%0V~KYOcdc3y=i)tnMU z)r1ArwvZGPd@A*83j2YKOkT~Zt3Au2(v#@9%#E^FuykFBD{>D=4(eLK{IUR7;6_Y} zCws_Ek`@+{pb9uuHwCOD6VmRQ9vR2=eLX}|a*t3_a!=`Y2r=W-XsQgSS5y~Jg1Z$X z*G$Guz)w42tOcqpdgAo_6yII_%hk2c^~A5PKb+5dkLI0yIl6Bf=FvW_5{+qEeG@u= zx=*iBbR4WK*c4`0!?nn;FMIT6n%GB@kUIvy?QW>vqS`^-_fZtpqKjb8CI6J^42<7I z_bn{C>_o^3^NZ+v<^?K5P5heZ-4yOs>7}Ho=-MXqST>6rp;hf?$#s1<jo(ra$Ch{* zPMdC7h>ntpj1WExw~|lEZ-vV%$)}fVq5Ae~&`!>C@;Baf`XlA9-usRB4>D1}kNp7( zu7#NwS5$B8@<3#KaPrzj?BeLe6o(tgZt}(FbPj@5UyBdHiZ{s8094;<A7RsT@^8I) z&&j;=WR5<Gn_)t=X!mTNzR1D;G5ofF4w4$$ijX%}6prde#<8tD(7i~cqEyvgCgW38 z*<*^Ds_roxdpP>^ErfXBX3_GJwN_)TXnx5Go3Ww_2lPw0W{f?eC2BS<FYiGpN|&EN zb5V=&lmPIsdr%|9PgWGpZqS4iXL-qc9mX1iF2A2H`C1i)a~gYqu4{%drXw1#F`o1` zzMon(E<Jmwf$ULw#%0imfc%xE4bAwBu}F>4+ieYMoHuM1t*z+Q{LPT#tCa|FT3HCY zqAhBRkh@q>IA7G#OL2a#CTi`adMV?ijv~&z1Sk6@b>BQ;aUpS!If+!$X6^KJgjlqS z5@jO9yj7GF<o-e-nrF~N?`vqLRUaW9gFguT+W*L@2t@6iiwmAIv*;l3D%uC^ix$0I zpcYWS`5;=LH2YMSxev5ojZp|3zyfgw0IAY^5))5f2|xf<4D-np;1BTj;NKM~1&~2h zGzg)=ft0kk2n2YUy~SgZc-Jr+(VU{pPT`EeCKvVqpRhw^4A-rCvG8XZK?dioLm2R> zfT&b}9wnqB46A+sYB!ToMlQP+l`)u9J>a%QnU52g7z3L+3PSI0W|xy1sv=l#KZzcL zLeG8znpE<QWI7FUsI~-NiQ$Zx=>f8(e!nIX!B57r@2HGTXXK@+i44IJ0~tjUrDb5B z9X!uwME)iylb=rs``%QY1o#)maHseP5L4az0Ho^P!~AGs)zU#;yxRN<IJr7v2yOsO zRtHrH_C0nFHPB`MAsNP&c?FQO@gOCV3rX#1wS$}r`0Q|G07J?EYs*5gNfCrS%^o5! zixJ+9$CBcL6!NL&MF9Ylc3!Y*E1edVCbO6r4%L*H$Ha%KW>LC@$$e4H86}Rv_ae1< zAUZWUF?o41%EcxoxwkLHE=*p!G<0EVbaFy<l&~S7OVwRsS_dAgXSu|7>DfN?Zd^!{ z6BBaa*MTx7_7jIlMR0(oY8-~1Y5|0VS5z9}g%m-0awesyW{^lG_7b3bsp#&Og%hgd z`&e1a$ptc43S96Y?COG_{1fm}#KUhnkS}<5<<81l(|RQDJzVgHAD+)%EY`I?NZm{2 z+K=b!P890Sd|H#8EZXV{wx$hR)7qPPTibdnZ|f_11J4@XC^U3!G<4;<&*dAwpQD3C zZ)?HZw&87CKb`l63*O_8+JIp2z=PR)v$?jueDGLy;wxv%-(38=i6@NbuIG-Y=(@IJ zHrX9pR$%6t&HKT7@4xraXu;OHVQVEzoOhhLz|bZ$T=dpHxA{M~{r>F@TT5wku0FcS zTm?>GckuOMpyf&9Yrkmu>y}?G{VMfID&KhQg^hMwvqM`Bw<Y+*+j#f(o!dX3E*u!x zfIsi~59wlk^MfDW`(e@DvL@zSM~d!1uKxHVAy;=c=jz|Km>V45Aajl5L+cKM>|Vl5 z(G@ManhGtw8!f$$UN7{GZ1j!nSj->*gM5J(epvnOP7QJew~-0nsSBB_@BdW(a_U3R z1`~PW4gB|(1?K)oJ`Z2xM}y%J7xfQE&knQb@eyjcm3|!Vf&Lc<C>Z~ul_GuCF=97= z(HR_}&0n0e!1!-z3yl2MZic?<j`Q*^_Ei}p%8ZwwJG=!Ua-PZQ-u^H6`u9-}5>4<k zN0H&b6X4Naf=ejFH?nBD{kW+EMay9V@%q!gGeDCE$p4c4Wn{$yzKswrDhgM|QDtrj zHFH(WMelMmL!{D56lpvFcAT+RjJ=$aWe+95+<J*3V^pL8Q<SmEX}}D8@TFW?qqGMd z+au{@>s~~1`eWElG+bRKTjlN<?|1nzbfamyI-N2aG`>{GzIq{&frvdJK<K}m6v0*| z;}C3@QyqG4;tY665=uhy;IJOOLvEtvQA2ZKwRYbbiwVnVWkqc&xj3+G%jpykkx0xq zdl~W-CR2a^NjIwoA<!qTIi@87Vx>)Ly|g@^Nyrce@1Iz8D3TPDmn5v5UbT~cN)i`O ztG>z*Cd8;#J>jA{AY8zTd@HFeVe)275SSM4sgBs?(TUi#$;co_`UBH?UkgM+7Cb~2 z4nw6{b-7i>kXv<-uu(VMG9joLT;1H(#sr8uGkQiq*QJ1`V9hN2<RVmA^sJ#NJ6vpP z$zJ@**-!*a;J)L|)pcz$-9>LO=RKHX4t{0xesnP(I9i_V+hmSyxsa=|;B4P;wy$3- zIFD>Nj}#f-UGE)luAyg>>3w2rSa;`bM>hK|eA%Dxn=Vft-DLWn`wy%w=l$K;v7*iY z@mSv0TAGF+_nq}ixrU?v!t@pGwI5&4+gW2Ov_7_eHrLR*$sB!dYgzk2!Pd25>nhXj z++@0*IsG|jbB=D-;<M_3+5dsT(F3#HtV48fuu;CQpr2CN1AnQ#@K-Yx)nKm)j77<? ziPTJ$YYRYnA0^QQMyDDj`jug#8Ln!zU1g(D8KiOM2!RR}#mEDIF{w_k92Qp6gHY8H zwN&NdRe_>4YOT7bY#J)x^GEo$&A1Jbrbi6TXW$E@??f5HBy}Ot5p_hFs6A@p>|t7T z&Xhy22+0Li6wWdIaQbH_$fbh9q8XBQ(8{!vbDlDZuBgMH&3AxN2M42<sB2G{0tU6R z7#vof0ys~uj+fD?62iTj4ueLh2~Mkzl5h%s9xe27tzItWK_O%@tgN7=Di1?4c^8g_ z%Guhgkx(fE8wPMGlkBV(VPGP!4j75qQtg^iCJAEb8YWovm%ET{)6&f$TD7KmSr!ub zZ9*YJHrJM6q5&L*#OjqjNn9Oq0u0x;>X2Nwk$T^-lloH?axg)%o2B#|!MrX^E-WTa zRrOyTxHLLAFf|m53~^T`C%7SeoJ<aqNyz`;WLmSRm|P+JJyJ1bs^L5PVpj$@ZfLOD z%<#bIrJ=!)TgTd036U96k;A3i{%fQ+O)BCAb`^x*A&<nIt9DVir6+!6au-UtTW@rC z;4jIz3&Zd{th^0B`E5A61a1R_bT+S@*lasiXdBvS8_GL|3-oY~9xnRp9yH!-Ts!-) zMl;;c+3$ZA%(EkfrjZ<7|I8bxg3)zIz21`#_7#FBAI%p6=QaZ8@_~Wu<)W|Qf$N@Y zZ6@yv6?{DpBiYfSuknHVo_p;^-q%_1^*)?059L~q=6!tyA0gpAx8XgP_YPz)KCkb5 z*q^Vzut5jFgZb-!`c8HjAa-qIv8Dk~cJW}}3%jMpnjPEmAdB;Z@%P7z%sb%2));`o z7VC}!`CnfbHhdSdqyJ`eKk>A#t!#FlDRfS4bWY_x(E=0AF;O7s3g+m*jv09mR$EYX z24MR4=a6ND`n?r3U!-32A^TP8KbYtrWb(0o)H>8pty4qC=ubMJ_ql}{dXxU#I%tB2 z#|$-eihgWwC;e_}=nVb%h>i5$q)2^AtIv=+bV#+uVhJf8i>Wpe9|Bat#3E?XpdJYJ zL+&}O<*dV6+LYiNfIxEC$U%pVSQ(&^p8AiK0xvlBIvOFkL~Y(Bq-8jDqWa;&#NFs; z{7~gG@{fhsl8_?dZuu43Wl~$Kt=+RoyKG*vC@x_EDsdsXs7JNkWE3J`%$H477&7r; zzMME9%SA}i5Gk}MxrS`X!2fFGurx0V7}E5ZTng0=@%1H=jln7ti;0lfkHz$>J4wX4 zLMpK25LCmvR5M9ilkAmB&r4EDLt~|zuVm6GL8ViGRQ3B$ZW1QhDh+z*7mhg#fpsP& zyoF_$Al^Y%q1rN26!pTyQ1o^HQQm(=_J2ojd_$Wl(>DyF#;B+0{8M!HDe8ENE<8n{ zXLet~-t^D*rnQrKdq>v#%<avRNYvxcI*K)CvfeE#MIHM{-a^o<$vdRkVkqj=x|s}} zdPtL@Q(H`!3O?%GLeP8`*dfi<Jk?6Itiw)d9>ulhQ{l70FKa&^{Zh$IUHiwC+~D-a mTYs`c7HplTkjb@WYNLYdr?(I^k2<$Wvu%d)7i3(M{r>@~YA;Iw literal 0 HcmV?d00001 diff --git a/src/agent/nodes/emulator/_rag_query.py b/src/agent/nodes/emulator/_rag_query.py new file mode 100644 index 0000000..d927fc0 --- /dev/null +++ b/src/agent/nodes/emulator/_rag_query.py @@ -0,0 +1,68 @@ +"""Query builder shared by the emulator RAG retrieve nodes. + +Flattens the operator's `selected_actions` (recursive `AnswerNode` tree — +see `anatel_response_emulator_schemas.AnswerNode`) plus complaint metadata +into a single free-text query suitable for vector search. +""" + +from typing import Any + +from src.agent.state.agent_state import AgentState + +_FALLBACK_QUERY = "resposta padrao anatel" + + +def _flatten_answer_values(node: Any) -> list[str]: + """Walks an `AnswerNode` tree and returns every primitive `value` as text. + + Skips dynamic branch-recursion keys; only collects the `value` payloads. + """ + out: list[str] = [] + if isinstance(node, dict): + for key, child in node.items(): + if key == "value": + if child not in (None, ""): + out.append(str(child).strip()) + elif isinstance(child, (dict, list)): + out.extend(_flatten_answer_values(child)) + elif isinstance(node, list): + for item in node: + out.extend(_flatten_answer_values(item)) + return out + + +def _extract_action_parts(action: Any) -> list[str]: + """Returns query terms for one selected action (predefined or custom).""" + if not isinstance(action, dict): + return [] + + parts: list[str] = [] + title = (action.get("action_title") or "").strip() + if title: + parts.append(title) + + action_type = action.get("type") + if action_type == "predefined": + parts.extend(_flatten_answer_values(action.get("answers") or {})) + elif action_type == "custom": + custom_text = (action.get("custom_text") or "").strip() + if custom_text: + parts.append(custom_text) + return parts + + +def build_rag_query(state: AgentState) -> str: + """Builds the vector-search query from selected actions + complaint.""" + metadata = state.get("metadata") or {} + actions = metadata.get("selected_actions") or [] + + parts: list[str] = [] + for action in actions: + parts.extend(_extract_action_parts(action)) + + case = (metadata.get("case_data") or {}).get("complaint") or {} + modality = (case.get("modality") or "").strip() + motive = (case.get("motive") or "").strip() + parts.extend(filter(None, [modality, motive])) + + return " ".join(parts).strip() or _FALLBACK_QUERY diff --git a/src/agent/nodes/emulator/approve_draft_node.py b/src/agent/nodes/emulator/approve_draft_node.py new file mode 100644 index 0000000..a59a024 --- /dev/null +++ b/src/agent/nodes/emulator/approve_draft_node.py @@ -0,0 +1,71 @@ +"""Records the operator's approval of the draft response. + +Runs on flow_mode="approve". Stages the new transition; the executor turns +that into `processing.status="approved"` in the final TicketResponseEvent. + +No direct DB write — the CMS owns persistence. The route enforces +pre-conditions (draft present, not already approved, not already closed) +before invoking the graph, so this node trusts the case state it loads. +""" + +import logging + +from src.agent.state.agent_state import AgentState, set_error, update_state_metadata +from src.agent.state.step_helpers_emulator import set_current_step +from src.agent.state.steps_emulator import EmulatorGraphStep +from src.agent.state.transitions_emulator import append_transition +from src.utils.observer import trace_node + +logger = logging.getLogger(__name__) + +_APPROVED = "approved" + + +def _fail(state: AgentState, msg: str) -> AgentState: + logger.error(msg) + return set_error( + state, + "ApproveDraftError", + msg, + step=EmulatorGraphStep.APPROVE_DRAFT_FAILED, + ) + + +@trace_node +async def approve_draft(state: AgentState) -> AgentState: + """Stages the `approved` transition; executor publishes the final event.""" + + await set_current_step(state, EmulatorGraphStep.APPROVE_DRAFT) + + metadata = state.get("metadata", {}) or {} + transaction_id = metadata.get("transaction_id") + case_data = metadata.get("case_data") or {} + processing = case_data.get("processing") or {} + case_response = processing.get("case_response") + + if not transaction_id: + return _fail(state, "transaction_id missing — cannot approve draft") + if not case_response: + return _fail(state, "case_response missing — nothing to approve") + + previous_status = processing.get("status") + new_transitions = append_transition( + processing.get("transitions"), + event="approved", + from_status=previous_status, + to_status=_APPROVED, + ) + + logger.info( + "Draft approval staged | transaction_id=%s | from_status=%s", + transaction_id, + previous_status, + ) + + new_state = update_state_metadata( + state, + case_response=case_response, + transitions=new_transitions, + ) + await set_current_step(new_state, EmulatorGraphStep.DRAFT_APPROVED) + return new_state diff --git a/src/agent/nodes/emulator/close_case_node.py b/src/agent/nodes/emulator/close_case_node.py new file mode 100644 index 0000000..30727d2 --- /dev/null +++ b/src/agent/nodes/emulator/close_case_node.py @@ -0,0 +1,290 @@ +"""Closes the case once the operator approves the draft response. + +1. Stages `processing.status="done"` (via `transitions`) for the executor + to surface in the final TicketResponseEvent. +2. Closes the Siebel Service Request (statusServiceRequest for pós-pago, + PATCH serviceRequest for pré-pago/express). Siebel failures do NOT block + the close — the error is recorded in `metadata.siebel_sr_closing_error` + for manual retry; the executor still publishes the terminal event. + +No direct DB write and no per-node OCI publish: the CMS owns persistence +and the executor publishes the terminal event after the graph completes, +matching the checklist flow. + +`dry_run` (test routes) skips the Siebel close. +""" + +import logging +from datetime import datetime + +from src.compat.framework_observer import event + +from src.agent.state.agent_state import AgentState, set_error, update_state_metadata +from src.agent.state.step_helpers_emulator import set_current_step +from src.agent.state.steps_emulator import EmulatorGraphStep +from src.agent.state.transitions_emulator import append_transition +from src.api.schemas.siebel_schemas import ( + SiebelSRStatusRequestPosPago, + SiebelSRStatusRequestPrePago, +) +from src.components.clients.exceptions.siebel_exceptions import SiebelClientError +from src.components.clients.siebel_client import SiebelClient +from src.core.config import settings +from src.infrastructure.oci.autonomous.memory_manager import get_memory +from src.utils.ics_collector import build_ic_payload, build_noc_api_metadata +from src.utils.observer import trace_node + +logger = logging.getLogger(__name__) + + +def _resolve_sr_protocol(case_data: dict) -> str | None: + """Pulls the Siebel SR protocol number from the persisted case doc. + + Canonical location is `processing.crmProtocol` — populated by the + checklist's `agent_helpers.build_cms_response_event` after + `siebel_sr_opening_node` opens the SR. Fallback is + `siebel_sr_data.interactionProtocol` (in-memory, used when close runs + in the same process as a fresh opening, before the doc round-trips + through the CMS — relevant for tests). + + Root `case_data.crmProtocol` is NOT used because the simulator/CMS + sometimes echoes a ticketId-shaped placeholder there (e.g. + `"DS-987654321"`) that is not a real Siebel SR protocol — closing + with it would PATCH the wrong SR. + """ + processing = case_data.get("processing") or {} + sr_data = case_data.get("siebel_sr_data") or {} + return processing.get("crmProtocol") or sr_data.get("interactionProtocol") + + +async def _resolve_plan_type(case_data: dict, complaint_protocol: str | None) -> tuple[str, str]: + """Returns (plan_type, source) — case_data first, then `memory` cache. + + Why two sources: the checklist's `build_cms_response_event` does NOT + publish `imdb_access_data` to the `cases` doc, so the close path can't + read plan info from `case_data` directly. The enrichment IS persisted + in the `memory` collection (`save_state_to_memory`, keyed by + `complaintProtocol`) — that's the canonical source on the close path. + Defaults to `"pós-pago"` only as last-resort, which would route the SR + close incorrectly for pré-pago/express. `source` is returned so the + caller can log which branch resolved the value (helps debug when the + SR is closed on the wrong endpoint). + """ + plan_info = (case_data.get("imdb_access_data") or {}).get("plan") or {} + plan_type = plan_info.get("Type") or plan_info.get("type") + if plan_type: + return plan_type, "case_data" + + if complaint_protocol: + memory_doc = await get_memory(complaint_protocol) + if memory_doc: + plan_info = (memory_doc.get("imdb_access_data") or {}).get("plan") or {} + plan_type = plan_info.get("Type") or plan_info.get("type") + if plan_type: + return plan_type, "memory" + + return "pós-pago", "default" + + +async def _close_siebel_sr(case_data: dict, case_response: str) -> dict: + """Closes the SR in Siebel. + + Raises ValueError for incomplete payload and SiebelClientError for + network/HTTP failures — caller decides the policy (current config does + not block close_case on Siebel failure). + """ + protocol_number = _resolve_sr_protocol(case_data) + complaint_protocol = (case_data.get("complaint") or {}).get("complaintProtocol") + + plan_type, plan_source = await _resolve_plan_type(case_data, complaint_protocol) + is_prepago = plan_type.strip().lower() in {"pré-pago", "express"} + logger.info( + "Siebel close plan_type resolved | plan_type=%s | source=%s", + plan_type, plan_source, + ) + + if not protocol_number: + raise ValueError( + "crmProtocol missing — checked processing.crmProtocol and " + "siebel_sr_data.interactionProtocol. Has the treatment SR " + "been opened?" + ) + if not complaint_protocol: + raise ValueError("complaint.complaintProtocol missing") + + notes = SiebelSRStatusRequestPosPago.build_notes(complaint_protocol, case_response) + + if is_prepago: + msisdn = (case_data.get("customer") or {}).get("msisdn") + if not msisdn: + raise ValueError("customer.msisdn ausente — obrigatório para pré-pago") + sr_status_request = SiebelSRStatusRequestPrePago( + protocol=protocol_number, + msisdn=msisdn, + notes=notes, + close_date=datetime.now().strftime("%d-%m-%Y %H:%M:%S"), + ) + else: + sr_status_request = SiebelSRStatusRequestPosPago( + protocol_number=protocol_number, + notes=notes, + date=datetime.now().strftime("%m/%d/%Y %H:%M:%S"), + ) + + client = SiebelClient( + client_id=settings.SIEBEL_API_CLIENT_ID, + username=settings.SIEBEL_API_USERNAME, + password=settings.SIEBEL_API_PASSWORD, + ) + + logger.debug( + f"Closing SR {protocol_number} for complaint {complaint_protocol} (plan_type={plan_type})" + ) + + response, http_meta = await client.update_service_request_status_with_retry( + payload=sr_status_request.to_payload(), + max_retries=2, + plan_type=plan_type, + ) + return response, http_meta + + +@trace_node +async def close_case(state: AgentState) -> AgentState: + """Stages the `closed` transition and closes the Siebel SR.""" + + await set_current_step(state, EmulatorGraphStep.CLOSE_CASE) + session_id = state.get("session_id", "") + + metadata = state.get("metadata", {}) or {} + transaction_id = metadata.get("transaction_id") + case_response = metadata.get("case_response") or ( + metadata.get("request_context") or {} + ).get("case_response") + dry_run = bool(metadata.get("dry_run")) + + if not transaction_id: + msg = "transaction_id missing in metadata — cannot close case" + logger.error(msg) + return set_error(state, "CloseCaseError", msg, step=EmulatorGraphStep.CLOSE_CASE_FAILED) + + if not case_response: + # On the close path we expect a previously approved draft sitting in + # `processing.case_response`. If it's absent, either the case was + # never generated/approved (route precondition failed silently) or + # the CMS callback hasn't applied the prior TicketResponseEvent yet + # (approve → close race). Don't blame "generation" — close doesn't + # call the LLM. + case_data = metadata.get("case_data") or {} + processing = case_data.get("processing") or {} + msg = ( + "case_response missing on close — no approved draft found in " + f"processing.case_response (status={processing.get('status')!r}). " + "Approve the draft first; if status='approved', the CMS may not " + "have persisted the draft yet (retry shortly)." + ) + logger.error(msg) + return set_error(state, "CloseCaseError", msg, step=EmulatorGraphStep.CLOSE_CASE_FAILED) + + event("AGA.041", { + "status": "Atualizar ID reclamação para respondido: solicitação de finalização recebida", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.041", + "call_id": session_id, + "origin": "EMULADOR", + **build_ic_payload(state, "AGA.041"), + }) + + case_data = metadata.get("case_data") or {} + processing = case_data.get("processing") or {} + new_transitions = append_transition( + processing.get("transitions"), + event="closed", + from_status=processing.get("status"), + to_status="done", + ) + + event("AGA.042", { + "status": "Encerrar ID reclamação no agente", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.042", + "call_id": session_id, + "origin": "EMULADOR", + **build_ic_payload(state, "AGA.042"), + }) + + if dry_run: + logger.info("dry_run=True — skipping Siebel close") + new_state = update_state_metadata( + state, + case_response=case_response, + transitions=new_transitions, + ) + await set_current_step(new_state, EmulatorGraphStep.CASE_CLOSED) + return new_state + + siebel_closing_response = None + siebel_closing_error = None + + await set_current_step(state, EmulatorGraphStep.SIEBEL_SR_CLOSING) + try: + siebel_closing_response, http_meta = await _close_siebel_sr(case_data, case_response) + logger.info("Siebel SR closed successfully | transaction_id=%s", transaction_id) + event("AGA.043", { + "status": "Encerrar ID reclamação no Siebel: sucesso", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.043", + "call_id": session_id, + "origin": "SIEBEL", + **build_ic_payload(state, "AGA.043"), + }) + event("NOC.008", { + "status": "Chamado fechado no Siebel com sucesso", + "type": "INFO", + **build_noc_api_metadata( + state, "NOC.008", + retry_count=http_meta.get("retry_count", 0), + latency_ms=int(http_meta.get("latency_ms", 0)), + api_url=http_meta.get("url", ""), + status_code=int(http_meta.get("status_code", 0)), + ), + }, metadata={"noc": True}) + except ValueError as exc: + siebel_closing_error = {"type": "ValidationError", "message": str(exc)} + logger.warning( + "Siebel SR closing skipped due to incomplete payload: %s | transaction_id=%s", + exc, + transaction_id, + ) + except SiebelClientError as exc: + siebel_closing_error = {"type": type(exc).__name__, "message": str(exc) or repr(exc)} + logger.exception( + "Failed to close SR in Siebel | transaction_id=%s | %s", + transaction_id, + siebel_closing_error["message"], + ) + + if siebel_closing_error: + new_state = update_state_metadata( + state, + case_response=case_response, + siebel_sr_closing_data=None, + siebel_sr_closing_error=siebel_closing_error, + ) + new_state = set_error(new_state, "SiebelClosingError", siebel_closing_error["message"], step=EmulatorGraphStep.SIEBEL_SR_CLOSING_FAILED) + await set_current_step(new_state, EmulatorGraphStep.SIEBEL_SR_CLOSING_FAILED) + return new_state + + new_state = update_state_metadata( + state, + case_response=case_response, + transitions=new_transitions, + siebel_sr_closing_data=siebel_closing_response, + siebel_sr_closing_error=None, + ) + await set_current_step(new_state, EmulatorGraphStep.SIEBEL_SR_CLOSED) + await set_current_step(new_state, EmulatorGraphStep.CASE_CLOSED) + return new_state diff --git a/src/agent/nodes/emulator/fetch_case_node.py b/src/agent/nodes/emulator/fetch_case_node.py new file mode 100644 index 0000000..f5d7b48 --- /dev/null +++ b/src/agent/nodes/emulator/fetch_case_node.py @@ -0,0 +1,104 @@ +"""Loads the case document from Autonomous DB into `metadata.case_data`.""" + +import json +import logging +import time + +from src.compat.framework_observer import event + +from src.agent.state.agent_state import AgentState, set_error, update_state_metadata +from src.agent.state.step_helpers_emulator import set_current_step +from src.agent.state.steps_emulator import EmulatorGraphStep +from src.core.config import settings +from src.infrastructure.oci.autonomous.connection import db_manager +from src.utils.ics_collector import build_ic_payload +from src.utils.observer import trace_node + +logger = logging.getLogger(__name__) + + +@trace_node +async def fetch_case(state: AgentState) -> AgentState: + """Fetches the case by `transactionId` into `state.metadata.case_data`.""" + + await set_current_step(state, EmulatorGraphStep.FETCH_CASE) + session_id = state.get("session_id", "") + transaction_id = state.get("metadata", {}).get("transaction_id") + + if not transaction_id: + msg = "transaction_id missing in state metadata" + logger.error(msg) + return set_error(state, "FetchCaseError", msg, step=EmulatorGraphStep.FETCH_CASE_FAILED) + + if db_manager.db is None: + msg = "Autonomous DB unavailable — cannot fetch case" + logger.error(msg) + return set_error(state, "FetchCaseError", msg, step=EmulatorGraphStep.FETCH_CASE_FAILED) + + collection_name = settings.AUTONOMOUS_NOSQL_COLLECTION + _t0 = time.perf_counter() + try: + case_data = await db_manager.get_data( + "transactionId", + transaction_id, + collection=collection_name, + ) + except Exception as exc: + msg = f"Error fetching case {transaction_id}: {exc}" + logger.error(msg, exc_info=True) + return set_error(state, "FetchCaseError", msg, step=EmulatorGraphStep.FETCH_CASE_FAILED) + + if not case_data: + msg = f"Case not found for transactionId={transaction_id}" + logger.error(msg) + return set_error(state, "FetchCaseError", msg, step=EmulatorGraphStep.FETCH_CASE_FAILED) + + latency_ms = int((time.perf_counter() - _t0) * 1000) + + logger.info( + "Case fetched | transaction_id=%s | protocol=%s", + transaction_id, + (case_data.get("complaint") or {}).get("complaintProtocol"), + ) + + siebel_sr_data = case_data.get("siebel_sr_data") or {} + processing = case_data.get("processing") or {} + response_summary = { + "complaintProtocol": (case_data.get("complaint") or {}).get("complaintProtocol"), + "crmProtocol": processing.get("crmProtocol") or siebel_sr_data.get("interactionProtocol"), + "status": processing.get("status"), + } + event("AGA.019", { + "status": "Registro da busca dos detalhes do chamado no Siebel para Resposta: sucesso", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.019", + "call_id": session_id, + "origin": "EMULADOR", + **build_ic_payload(state, "AGA.019", { + "apiUrl": f"autonomous_db/{collection_name}", + "apiStatusCode": 200, + "apiResponsePayload": json.dumps(response_summary, ensure_ascii=False), + "latencyMs": latency_ms, + }), + }, metadata={"noc": True}) + + # Mirror persisted draft into metadata so close_case_node finds it + # on the flow_mode="close" path. Overwritten by generate_response_node + # on the flow_mode="generate" path. + persisted_response = (case_data.get("processing") or {}).get("case_response") + + if persisted_response: + return update_state_metadata( + state, + case_data=case_data, + case_response=persisted_response, + ) + return update_state_metadata(state, case_data=case_data) + + +def should_continue(state: AgentState) -> str: + """Blocks the graph on fetch error.""" + if state.get("error"): + return "failed" + return "continue" diff --git a/src/agent/nodes/emulator/generate_response_node.py b/src/agent/nodes/emulator/generate_response_node.py new file mode 100644 index 0000000..d7f15b4 --- /dev/null +++ b/src/agent/nodes/emulator/generate_response_node.py @@ -0,0 +1,461 @@ +"""Builds the final prompt (case + actions + KBs) and calls the LLM. + +The generated text is written to `metadata.case_response` and also mirrored +into `metadata.request_context.case_response` for downstream readers. +""" + +import json +import logging +import re +from typing import Any + +from src.compat.framework_observer import event + +from src.agent.local_prompts.emulator.response_emulator_generation import ( + OPENING_GREETING, + OPENING_PROTOCOL_NO_CRM, + OPENING_PROTOCOL_WITH_CRM, + OUVIDORIA_BY_SEGMENT, + OUVIDORIA_DEFAULT, + response_emulator_generation_pt, +) +from src.agent.state.agent_state import AgentState, set_error, update_state_metadata +from src.agent.state.step_helpers_emulator import set_current_step +from src.agent.state.steps_emulator import EmulatorGraphStep +from src.core.prompt_manager import get_prompt_with_config +from src.providers.llm_provider import chat_llm_with_usage, classification_large_llm +from src.utils.ics_collector import ( + build_ic_payload, + build_llm_token_fields, + build_rag_telemetry_fields, +) +from src.utils.observer import trace_node + +logger = logging.getLogger(__name__) + +# Pós-pago segment tokens (Digital operates as pós-pago in IMDB). +# Anything else falls to "default". +_POSPAGO_PLAN_TOKENS = ("pós-pago", "pos-pago", "pospago", "digital") + +# Fallback when Langfuse `config.max_regeneration_history` is absent or invalid. +_DEFAULT_MAX_REGENERATION_HISTORY = 4 + + +def _resolve_segment(case_data: dict) -> str: + """Classifies the segment from `imdb_access_data.plan.Type`.""" + plan_info = (case_data.get("imdb_access_data") or {}).get("plan") or {} + plan_type = (plan_info.get("Type") or plan_info.get("type") or "").strip().lower() + + if any(token in plan_type for token in _POSPAGO_PLAN_TOKENS): + return "pospago" + return "default" + + +def _resolve_crm_protocol(case_data: dict) -> str | None: + """Treatment SR protocol (processing → siebel_sr_data); root crmProtocol ignored (simulator placeholder).""" + processing = case_data.get("processing") or {} + sr_data = case_data.get("siebel_sr_data") or {} + return processing.get("crmProtocol") or sr_data.get("interactionProtocol") + + +def _select_case_fields(case_data: dict) -> dict: + """Picks only the case fields the prompt needs.""" + customer = case_data.get("customer") or {} + complaint = case_data.get("complaint") or {} + return { + "crmProtocol": _resolve_crm_protocol(case_data), + "complaint": { + "complaintProtocol": complaint.get("complaintProtocol"), + "service": complaint.get("service"), + "modality": complaint.get("modality"), + "motive": complaint.get("motive"), + "description": complaint.get("description"), + "openedAt": str(complaint.get("openedAt")) if complaint.get("openedAt") else None, + "dueAt": str(complaint.get("dueAt")) if complaint.get("dueAt") else None, + "actionType": complaint.get("actionType"), + }, + "customer": { + "name": customer.get("name"), + "cpfCnpj": customer.get("cpfCnpj"), + "msisdn": customer.get("msisdn"), + "phones": customer.get("phones") or [], + "address": customer.get("address") or {}, + }, + "subscriber": customer.get("subscriber") or {}, + } + + +def _resolve_max_regeneration_history(config: dict) -> int: + """Reads the loop-safety cap from Langfuse prompt config. + + Only accepts positive integers. Anything else falls back to the default + so that a typo in Langfuse can't disable the safeguard. + """ + raw = config.get("max_regeneration_history") + if isinstance(raw, int) and raw > 0: + return raw + return _DEFAULT_MAX_REGENERATION_HISTORY + + +def _build_regeneration_block(state: AgentState, max_history: int) -> dict | None: + """Builds the rolling window of past rejections (+ current one). + + Combines `processing.feedback_history` with the current rejection from + `request_context` and trims to the last `max_history` entries — without + this cap, every loop iteration inflates the prompt with another pair. + Returns `None` when not a regeneration or when required fields are absent. + """ + metadata = state.get("metadata", {}) or {} + request_context = metadata.get("request_context") or {} + if request_context.get("type") != "regenerate": + return None + + previous_response = request_context.get("previous_response") + feedback = request_context.get("feedback") + if not previous_response or not feedback: + return None + + # Persisted history entries carry `{response, feedback, registered_at}`; + # drop the timestamp — the prompt only needs the pair. + case_data = metadata.get("case_data") or {} + raw_history = (case_data.get("processing") or {}).get("feedback_history") or [] + iterations = [ + {"response": entry.get("response"), "feedback": entry.get("feedback")} + for entry in raw_history + if entry.get("response") and entry.get("feedback") + ] + iterations.append({"response": previous_response, "feedback": feedback}) + + if len(iterations) > max_history: + iterations = iterations[-max_history:] + + return {"iterations": iterations} + + +def _slim_rag_chunks(chunks: list[dict] | None) -> list[dict]: + """Strips RAG chunks down to the only field the LLM needs: `content`. + + The retriever returns `{id, content, distance, metadata}` per chunk, useful + for logs and debugging but pure token waste once it hits the model — the + LLM doesn't act on chunk ids, cosine distances or bucket-internal `nota` + scores. Empty/whitespace-only content is dropped: an empty chunk is just + noise the prompt itself already tells the model to skip. + """ + if not chunks: + return [] + slimmed: list[dict] = [] + for chunk in chunks: + content = (chunk.get("content") or "").strip() + if not content: + continue + slimmed.append({"content": content}) + return slimmed + + +def _build_prompt_payload(state: AgentState, max_regeneration_history: int) -> dict: + metadata = state.get("metadata", {}) or {} + case_data = metadata.get("case_data") or {} + payload: dict = { + "case": _select_case_fields(case_data), + "selected_actions": metadata.get("selected_actions") or [], + "kb_templates": _slim_rag_chunks(metadata.get("kb_templates")), + "kb_history_high_score": _slim_rag_chunks(metadata.get("kb_history_high_score")), + "kb_history_low_score": _slim_rag_chunks(metadata.get("kb_history_low_score")), + } + regeneration = _build_regeneration_block(state, max_regeneration_history) + if regeneration is not None: + payload["regeneration"] = regeneration + return payload + + +def _log_generation_inputs( + state: AgentState, + payload: dict, + prompt_body: str, + max_regeneration_history: int, + segment: str, +) -> None: + """Emits a structured snapshot of what feeds the LLM (debug for gen/regen).""" + regeneration = payload.get("regeneration") + is_regeneration = regeneration is not None + iterations = (regeneration or {}).get("iterations", []) if is_regeneration else [] + + # `get_prompt_with_config` returns the local fallback body verbatim when + # Langfuse is off/unavailable — identity check tells the two apart. + prompt_source = "local_fallback" if prompt_body is response_emulator_generation_pt else "langfuse" + + session_id = state.get("session_id", "") + logger.info( + "Generation inputs | session=%s | regeneration=%s | iterations=%d | " + "max_regeneration_history=%d | prompt_source=%s | segment=%s | " + "selected_actions=%d | kb_templates=%d | kb_history_high_score=%d | kb_history_low_score=%d", + session_id, + is_regeneration, + len(iterations), + max_regeneration_history, + prompt_source, + segment, + len(payload.get("selected_actions") or []), + len(payload.get("kb_templates") or []), + len(payload.get("kb_history_high_score") or []), + len(payload.get("kb_history_low_score") or []), + ) + + +def _extract_case_response(parsed: Any) -> str: + """Extracts the response body (`case_body`) from the LLM's parsed JSON. + + Accepts the legacy `case_response` key as a fallback so a stale Langfuse + prompt version (still asking for `case_response`) doesn't hard-fail — the + body-leak guard in `_strip_leaked_framing` cleans whatever comes back. + + Recebe o dict já parseado por `chat_llm_with_usage(..., expect_json=True)`. + """ + if not isinstance(parsed, dict): + raise ValueError("`case_body` field missing or invalid in LLM response") + body = parsed.get("case_body") or parsed.get("case_response") + if not body or not isinstance(body, str): + raise ValueError("`case_body` field missing or invalid in LLM response") + return body.strip() + + +# Residual openings the model may still emit at the start of the body despite the +# prompt — trimmed so they don't duplicate the deterministic opening block. +# +# Matches are anchored at the start and length-bounded: a leaked opener is a +# short clause, so we never let a pattern consume real body text. Each is sliced +# off (not regex-substituted), so a non-match leaves the body untouched. +_MAX_LEAK_LEN = 200 + +# Old IQI template opening: "Prezado(a) cliente NAME, protocolo 123:" / "..., 123 -". +_LEAKED_OLD_OPENING_RE = re.compile( + r"^(?:ol[áa]|prezad[oa]\(?a?\)?|car[oa])\b[^\n]*?protocolo\s+\d+\s*[:\-]\s*", + re.IGNORECASE, +) +# A bare greeting sentence: "Olá, NAME, espero que esteja bem!". +_LEAKED_GREETING_RE = re.compile( + r"^(?:ol[áa]|prezad[oa]\(?a?\)?|car[oa]|sra?\.?)[^\n.!?]*[.!?]\s*", + re.IGNORECASE, +) +# A leftover "Sua solicitação foi registrada ... ID 123." opener. +_LEAKED_PROTOCOL_RE = re.compile( + r"^sua solicita[çc][ãa]o foi registrada[^\n.!?]*[.!?]\s*", + re.IGNORECASE, +) +# Start of the ombudsman block (both segment variants share this opener). If the +# model leaks the block at the end of the body, everything from here on is cut — +# the deterministic block is appended afterwards regardless. +_LEAKED_OMBUDSMAN_RE = re.compile( + r"\bvoc[êe] sabia que a tim tem um canal de ouvidoria", + re.IGNORECASE, +) + + +def _strip_one_leak(text: str, pattern: re.Pattern) -> str: + """Slices off a single leading match of `pattern` if short enough to be framing.""" + match = pattern.match(text) + if match and match.end() <= _MAX_LEAK_LEN: + return text[match.end():].lstrip() + return text + + +def _strip_leaked_framing(body: str) -> str: + """Removes a greeting/protocol opener the model may have leaked into the body. + + The opening is added deterministically by `_assemble_case_response`; if the + model also wrote one, it would appear twice. Trims, in order, the old IQI + combined opening, a bare greeting sentence, and a leftover protocol line. + """ + text = body.strip() + text = _strip_one_leak(text, _LEAKED_OLD_OPENING_RE) + text = _strip_one_leak(text, _LEAKED_GREETING_RE) + text = _strip_one_leak(text, _LEAKED_PROTOCOL_RE) + + ombudsman = _LEAKED_OMBUDSMAN_RE.search(text) + if ombudsman: + text = text[: ombudsman.start()].rstrip() + + return text or body.strip() + + +def _assemble_case_response(case_fields: dict, segment: str, body: str) -> str: + """Wraps the LLM body with the fixed opening and ombudsman blocks (US format). + + Opening (greeting + protocols + Anatel ID) and the segment-specific + ombudsman block are deterministic — never left to the model. crmProtocol may + be null, in which case the protocol clause is dropped per the US rules. + """ + customer = case_fields.get("customer") or {} + complaint = case_fields.get("complaint") or {} + name = (customer.get("name") or "").strip() or "cliente" + crm_protocol = case_fields.get("crmProtocol") + complaint_protocol = complaint.get("complaintProtocol") or "" + + greeting = OPENING_GREETING.format(name=name) + if crm_protocol: + protocol_line = OPENING_PROTOCOL_WITH_CRM.format( + crm_protocol=crm_protocol, complaint_protocol=complaint_protocol + ) + else: + protocol_line = OPENING_PROTOCOL_NO_CRM.format(complaint_protocol=complaint_protocol) + opening = f"{greeting}\n\n{protocol_line}" + + ombudsman = OUVIDORIA_BY_SEGMENT.get(segment, OUVIDORIA_DEFAULT) + clean_body = _strip_leaked_framing(body) + + return f"{opening}\n\n{clean_body}\n\n{ombudsman}" + +@trace_node +async def generate_response(state: AgentState) -> AgentState: + """Calls the LLM for the body, then assembles + stores the full `case_response`. + + The LLM returns only the body (`case_body`); the deterministic opening and + segment-specific ombudsman blocks are added in code by + `_assemble_case_response`, guaranteeing the US structure. + """ + + await set_current_step(state, EmulatorGraphStep.GENERATE_RESPONSE) + + llm = classification_large_llm + + # Body + config in a single Langfuse fetch (shared cache). The history + # window cap lives in `config.max_regeneration_history` — editable in + # the Langfuse UI without a deploy. + prompt_body, prompt_config = get_prompt_with_config( + "response_emulator_generation_pt", + response_emulator_generation_pt, + {"max_regeneration_history": _DEFAULT_MAX_REGENERATION_HISTORY}, + ) + max_regeneration_history = _resolve_max_regeneration_history(prompt_config) + + case_data = (state.get("metadata", {}) or {}).get("case_data") or {} + segment = _resolve_segment(case_data) + + payload = _build_prompt_payload(state, max_regeneration_history) + _log_generation_inputs(state, payload, prompt_body, max_regeneration_history, segment) + message = ( + f"{prompt_body}\n\n[Payload]:\n" + f"{json.dumps(payload, ensure_ascii=False, default=str)}" + ) + + llm_resp = None + try: + llm_resp = chat_llm_with_usage(llm, message, expect_json=True) + case_body_text = _extract_case_response(llm_resp.parsed_json) + except json.JSONDecodeError as parse_err: + msg = f"LLM response is not valid JSON: {parse_err}" + logger.exception("%s | raw_content=%.500r", msg, getattr(llm_resp, "content", "")) + return set_error( + state, + "ResponseGenerationError", + msg, + step=EmulatorGraphStep.GENERATE_RESPONSE_FAILED, + ) + except ValueError as val_err: + logger.exception("Failed to parse LLM response | raw_content=%.500r", getattr(llm_resp, "content", "")) + return set_error( + state, + "ResponseGenerationError", + str(val_err), + step=EmulatorGraphStep.GENERATE_RESPONSE_FAILED, + ) + except Exception as exc: + msg = f"LLM invocation failed: {exc}" + logger.exception(msg) + return set_error( + state, + "ResponseGenerationError", + msg, + step=EmulatorGraphStep.GENERATE_RESPONSE_FAILED, + ) + + case_response_text = _assemble_case_response(payload["case"], segment, case_body_text) + + logger.info( + "Response generated | body_length=%d | full_length=%d | segment=%s | " + "prompt_tokens=%d | completion_tokens=%d | " + "total_tokens=%d | finish_reason=%s | latency_ms=%.0f", + len(case_body_text), + len(case_response_text), + segment, + llm_resp.prompt_tokens, + llm_resp.completion_tokens, + llm_resp.total_tokens, + llm_resp.finish_reason, + llm_resp.latency_ms, + ) + + # AGA.024 (first generation) or AGA.025 (regeneration after rejection). + # The emulator's TemplatesRAG returns chunks shaped {id, content, distance, metadata}; + # remap to the {documentId, title, distance} contract that build_rag_telemetry_fields + # expects. Title pulled from metadata.ITEM (the operator-facing template label). + # selected == retrieved here — there's no selection layer between RAG and LLM + # in the emulator today. + session_id = state.get("session_id", "") + kb_templates_raw = (state.get("metadata") or {}).get("kb_templates") or [] + templates_for_telemetry = [ + { + "documentId": t.get("id"), + "title": (t.get("metadata") or {}).get("ITEM"), + "distance": t.get("distance"), + } + for t in kb_templates_raw + ] + + request_context_meta = (state.get("metadata") or {}).get("request_context") or {} + is_regeneration = request_context_meta.get("type") == "regenerate" + + if is_regeneration: + case_data = (state.get("metadata") or {}).get("case_data") or {} + feedback_history = (case_data.get("processing") or {}).get("feedback_history") or [] + # 1 inicial (AGA.024) + N rejeições já registradas + esta nova regeneração + attempt_number = len(feedback_history) + 2 + event("AGA.025", { + "status": f"Geração de resposta com ajuste (LLM) no Emulador — tentativa {attempt_number}", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.025", + "call_id": session_id, + "origin": "LLM", + "step": "generate_response", + **build_ic_payload(state, "AGA.025", { + "agentSpecificData.attemptNumber": attempt_number, + "llmResponse": llm_resp.content, + "customerMessage": case_response_text, + **build_llm_token_fields(llm_resp), + }), + }) + else: + event("AGA.024", { + "status": "Registro da resposta (LLM) no Emulador", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.024", + "call_id": session_id, + "origin": "LLM", + "step": "generate_response", + **build_ic_payload(state, "AGA.024", { + "llmResponse": llm_resp.content, + "customerMessage": case_response_text, + **build_llm_token_fields(llm_resp), + **build_rag_telemetry_fields( + retrieved=templates_for_telemetry, + selected=templates_for_telemetry, + ), + }), + }) + + request_context = dict(state.get("metadata", {}).get("request_context") or {}) + request_context["case_response"] = case_response_text + return update_state_metadata( + state, + request_context=request_context, + case_response=case_response_text, + ) + + +def should_continue(state: AgentState) -> str: + """Blocks the graph on generation error.""" + if state.get("error"): + return "failed" + return "continue" diff --git a/src/agent/nodes/emulator/persist_draft_node.py b/src/agent/nodes/emulator/persist_draft_node.py new file mode 100644 index 0000000..24fde04 --- /dev/null +++ b/src/agent/nodes/emulator/persist_draft_node.py @@ -0,0 +1,91 @@ +"""Stages the generated response as a DRAFT. + +Runs at the end of flow_mode="generate". Builds the `transitions` array +that the executor will surface to the CMS as `processing.transitions` in +the final TicketResponseEvent, alongside `processing.status` = +`"awaiting_review"`. + +No direct DB write here: the CMS owns persistence (same contract as the +checklist flow). The executor publishes the terminal event on the OCI +Response Stream after the graph completes, guaranteeing it is the last +message for this case and avoiding races against late ProgressEvents. +""" + +import logging + +from src.agent.state.agent_state import AgentState, set_error, update_state_metadata +from src.agent.state.step_helpers_emulator import set_current_step +from src.agent.state.steps_emulator import EmulatorGraphStep +from src.agent.state.transitions_emulator import append_transition, regenerate_attempt +from src.utils.observer import trace_node + +logger = logging.getLogger(__name__) + +_AWAITING_REVIEW = "awaiting_review" + + +def _fail(state: AgentState, msg: str) -> AgentState: + logger.error(msg) + return set_error( + state, + "PersistDraftError", + msg, + step=EmulatorGraphStep.PERSIST_DRAFT_FAILED, + ) + + +def _build_transition(metadata: dict) -> tuple[str, int, str | None, list[dict]]: + """Returns (event, attempt, from_status, new_transitions_array).""" + processing = (metadata.get("case_data") or {}).get("processing") or {} + previous_status = processing.get("status") + previous_transitions = processing.get("transitions") or [] + + is_regenerate = (metadata.get("request_context") or {}).get("type") == "regenerate" + event = "regenerated" if is_regenerate else "generated" + attempt = regenerate_attempt(previous_transitions) if is_regenerate else 1 + + new_transitions = append_transition( + previous_transitions, + event=event, + from_status=previous_status, + to_status=_AWAITING_REVIEW, + attempt=attempt, + ) + return event, attempt, previous_status, new_transitions + + +@trace_node +async def persist_draft(state: AgentState) -> AgentState: + """Stages the draft in state.metadata; executor publishes the final event.""" + + await set_current_step(state, EmulatorGraphStep.PERSIST_DRAFT) + + metadata = state.get("metadata", {}) or {} + transaction_id = metadata.get("transaction_id") + case_response = metadata.get("case_response") or ( + metadata.get("request_context") or {} + ).get("case_response") + + if not transaction_id: + return _fail(state, "transaction_id missing — cannot persist draft") + if not case_response: + return _fail(state, "case_response missing — generate_response produced no text") + + event, attempt, previous_status, new_transitions = _build_transition(metadata) + + logger.info( + "Draft staged | transaction_id=%s | length=%d | event=%s | attempt=%s | from_status=%s", + transaction_id, + len(case_response), + event, + attempt, + previous_status, + ) + + new_state = update_state_metadata( + state, + case_response=case_response, + transitions=new_transitions, + ) + await set_current_step(new_state, EmulatorGraphStep.DRAFT_PERSISTED) + return new_state diff --git a/src/agent/nodes/emulator/retrieve_history_node.py b/src/agent/nodes/emulator/retrieve_history_node.py new file mode 100644 index 0000000..b990575 --- /dev/null +++ b/src/agent/nodes/emulator/retrieve_history_node.py @@ -0,0 +1,68 @@ +"""Operational-history RAG retrieve into `metadata.kb_history_high_score` / `kb_history_low_score`. + +Query is built by `_rag_query.build_rag_query` (action titles + flattened +answers + complaint modality/motive). Two independent buckets are fetched: +- `kb_history_high_score`: high-note approved responses (positive references); +- `kb_history_low_score`: low-note responses (negative examples to avoid). + +Failures are tolerant — empty buckets let the graph keep going. +""" + +import logging + +from src.agent.nodes.emulator._rag_query import build_rag_query +from src.agent.state.agent_state import AgentState, update_state_metadata +from src.agent.state.step_helpers_emulator import set_current_step +from src.agent.state.steps_emulator import EmulatorGraphStep +from src.components.clients.rag import history_rag_client +from src.core.config import settings +from src.utils.observer import trace_node + +logger = logging.getLogger(__name__) + + +def _distance_span(chunks: list[dict]) -> str: + """Min/max COSINE distance of a bucket, for retrieval-quality debugging.""" + distances = [c.get("distance") for c in chunks if c.get("distance") is not None] + if not distances: + return "n/a" + return f"{min(distances):.4f}..{max(distances):.4f}" + + +@trace_node +async def retrieve_history(state: AgentState) -> AgentState: + """Queries the history RAG (high_score + low_score buckets) into metadata.""" + + await set_current_step(state, EmulatorGraphStep.RETRIEVE_HISTORY) + + query = build_rag_query(state) + try: + buckets = await history_rag_client.search_examples( + query=query, + top_k_high_score=settings.EMULATOR_HISTORY_HIGH_SCORE_TOP_K, + top_k_low_score=settings.EMULATOR_HISTORY_LOW_SCORE_TOP_K, + ) + except Exception as exc: + logger.warning("HistoryRAG query failed: %s", exc, exc_info=True) + return update_state_metadata( + state, kb_history_high_score=[], kb_history_low_score=[] + ) + + high_score = buckets.get("high_score", []) + low_score = buckets.get("low_score", []) + logger.info( + "kb_history retrieved | high_score=%d (nota>=%d, top_k=%d, dist=%s) | " + "low_score=%d (nota<=%d, top_k=%d, dist=%s) | query=%r", + len(high_score), + settings.EMULATOR_RAG_HISTORY_HIGH_SCORE_THRESHOLD, + settings.EMULATOR_HISTORY_HIGH_SCORE_TOP_K, + _distance_span(high_score), + len(low_score), + settings.EMULATOR_RAG_HISTORY_LOW_SCORE_THRESHOLD, + settings.EMULATOR_HISTORY_LOW_SCORE_TOP_K, + _distance_span(low_score), + query, + ) + return update_state_metadata( + state, kb_history_high_score=high_score, kb_history_low_score=low_score + ) diff --git a/src/agent/nodes/emulator/retrieve_templates_node.py b/src/agent/nodes/emulator/retrieve_templates_node.py new file mode 100644 index 0000000..0fe0dde --- /dev/null +++ b/src/agent/nodes/emulator/retrieve_templates_node.py @@ -0,0 +1,38 @@ +"""IQI templates RAG retrieve into `metadata.kb_templates`. + +Query is built by `_rag_query.build_rag_query` (action titles + flattened +answers + complaint modality/motive). Failures are tolerant — empty list +lets the graph keep going. +""" + +import logging + +from src.agent.nodes.emulator._rag_query import build_rag_query +from src.agent.state.agent_state import AgentState, update_state_metadata +from src.agent.state.step_helpers_emulator import set_current_step +from src.agent.state.steps_emulator import EmulatorGraphStep +from src.components.clients.rag import templates_rag_client +from src.core.config import settings +from src.utils.observer import trace_node + +logger = logging.getLogger(__name__) + + +@trace_node +async def retrieve_templates(state: AgentState) -> AgentState: + """Queries the templates RAG and stores results in `metadata.kb_templates`.""" + + await set_current_step(state, EmulatorGraphStep.RETRIEVE_TEMPLATES) + + query = build_rag_query(state) + try: + templates = await templates_rag_client.search( + query=query, + top_k=settings.EMULATOR_TEMPLATES_TOP_K, + ) + except Exception as exc: + logger.warning("TemplatesRAG query failed: %s", exc, exc_info=True) + return update_state_metadata(state, kb_templates=[]) + + logger.info("kb_templates retrieved | count=%d | query=%r", len(templates), query) + return update_state_metadata(state, kb_templates=templates) diff --git a/src/agent/nodes/emulator/router_node.py b/src/agent/nodes/emulator/router_node.py new file mode 100644 index 0000000..452edf8 --- /dev/null +++ b/src/agent/nodes/emulator/router_node.py @@ -0,0 +1,60 @@ +"""Deterministic retrieve router for the Response Emulator graph. + +Writes the routing decision to `metadata.emulator_routing`; conditional +edges in `emulator_graph` consume it. Rules: +- `retrieve_templates`: always on. +- `retrieve_history`: always on. +""" + +import logging + +from src.agent.state.agent_state import AgentState, update_state_metadata +from src.agent.state.step_helpers_emulator import set_current_step +from src.agent.state.steps_emulator import EmulatorGraphStep +from src.utils.observer import trace_node + +logger = logging.getLogger(__name__) + + +@trace_node +async def route(state: AgentState) -> AgentState: + """Decides which retrieves to run and writes the flags to metadata.""" + + await set_current_step(state, EmulatorGraphStep.ROUTER_DECISION) + + metadata = state.get("metadata", {}) or {} + request_context = metadata.get("request_context") or {} + emulation_type = request_context.get("type") + + use_templates = True + use_history = True + + routing = { + "use_templates": use_templates, + "use_history": use_history, + "emulation_type": emulation_type, + } + + logger.info( + "Emulator router decision | templates=%s history=%s | type=%s", + use_templates, use_history, emulation_type, + ) + + return update_state_metadata(state, emulator_routing=routing) + + +def next_step_after_router(state: AgentState) -> str: + """LangGraph router: points to the first enabled retrieve.""" + routing = (state.get("metadata") or {}).get("emulator_routing") or {} + if routing.get("use_templates"): + return EmulatorGraphStep.RETRIEVE_TEMPLATES + if routing.get("use_history"): + return EmulatorGraphStep.RETRIEVE_HISTORY + return EmulatorGraphStep.GENERATE_RESPONSE + + +def next_step_after_templates(state: AgentState) -> str: + routing = (state.get("metadata") or {}).get("emulator_routing") or {} + if routing.get("use_history"): + return EmulatorGraphStep.RETRIEVE_HISTORY + return EmulatorGraphStep.GENERATE_RESPONSE diff --git a/src/agent/nodes/emulator/start_response_emulation_node.py b/src/agent/nodes/emulator/start_response_emulation_node.py new file mode 100644 index 0000000..ebfc8e4 --- /dev/null +++ b/src/agent/nodes/emulator/start_response_emulation_node.py @@ -0,0 +1,114 @@ +"""Entry node for the Response Emulator graph — in-flight status flip. + +This node flips `processing.status` in the Mongo case document to a +`processing`/`processing_regeneration` sentinel so the simulator's polling +on `GET /case/{tx}/response-emulator` sees a real status change while the +graph runs. The terminal `TicketResponseEvent` published by the executor +at the end overwrites this with the final outcome +(`awaiting_review` / `approved` / `done` / `failed`). + +Only the `generate` flow mode flips the status — `approve` and `close` +finish quickly and the terminal event publishes their outcome directly. +DB unavailability and `dry_run` are tolerated (warning only). +""" + +import logging +from datetime import datetime, timezone + +from src.agent.state.agent_state import AgentState +from src.agent.state.step_helpers import set_current_step +from src.agent.state.steps_emulator import EmulatorGraphStep +from src.api.schemas.anatel_schemas import ProcessingStatus +from src.core.config import settings +from src.infrastructure.oci.autonomous.connection import db_manager +from src.utils.observer import trace_node + +logger = logging.getLogger(__name__) + + +def _resolve_in_flight_status( + flow_mode: str | None, emulation_type: str | None +) -> ProcessingStatus | None: + """Returns the in-flight status to flip to, or None to skip the flip.""" + if flow_mode != "generate": + return None + if emulation_type == "regenerate": + return ProcessingStatus.PROCESSING_REGENERATION + return ProcessingStatus.PROCESSING + + +async def _flip_status_in_db( + transaction_id: str | None, + in_flight_status: ProcessingStatus, + dry_run: bool, +) -> None: + """Best-effort `$set processing.status` so the GET polling sees movement.""" + if not transaction_id: + logger.warning("transaction_id missing — skipping in-flight status flip") + return + if dry_run: + logger.info("dry_run=True — skipping in-flight status flip") + return + if db_manager.db is None: + logger.warning("Autonomous DB unavailable — skipping in-flight status flip") + return + + try: + coll = db_manager.db[settings.AUTONOMOUS_NOSQL_COLLECTION] + result = await coll.update_one( + {"transactionId": transaction_id}, + { + "$set": { + "processing.status": in_flight_status.value, + "processing.in_flight_since": datetime.now(timezone.utc), + } + }, + ) + logger.info( + "In-flight status flipped | transaction_id=%s | status=%s | matched=%s | modified=%s", + transaction_id, + in_flight_status.value, + result.matched_count, + result.modified_count, + ) + except Exception as exc: + logger.warning( + "Failed to flip in-flight status: %s | transaction_id=%s", + exc, transaction_id, exc_info=True, + ) + + +@trace_node +async def start_response_emulation(state: AgentState) -> AgentState: + """Marks the start of the response emulation flow.""" + + logger.info("Running start_response_emulation_node") + + await set_current_step(state, EmulatorGraphStep.RESPONSE_EMULATION_START) + session_id = state.get("session_id", "") + + metadata = state.get("metadata", {}) + selected_actions = metadata.get("selected_actions", []) or [] + request_context = metadata.get("request_context", {}) or {} + flow_mode = metadata.get("flow_mode") or request_context.get("flow_mode") + emulation_type = request_context.get("type") + + in_flight_status = _resolve_in_flight_status(flow_mode, emulation_type) + if in_flight_status is not None: + await _flip_status_in_db( + metadata.get("transaction_id"), + in_flight_status, + dry_run=bool(metadata.get("dry_run")), + ) + + logger.info( + "Response emulation entry node completed", + extra={ + "session_id": session_id, + "selected_actions_count": len(selected_actions), + "emulation_type": emulation_type, + "flow_mode": flow_mode, + "in_flight_status": in_flight_status.value if in_flight_status else None, + }, + ) + return state diff --git a/src/agent/nodes/emulator/validate_actions_node.py b/src/agent/nodes/emulator/validate_actions_node.py new file mode 100644 index 0000000..3ce08b2 --- /dev/null +++ b/src/agent/nodes/emulator/validate_actions_node.py @@ -0,0 +1,179 @@ +"""Pre-generation gate: blocks the graph when required action fields are empty. + +Predefined actions: walks the answer tree (PredefinedAction.answers → +AnswerNode → nested sub-answers) and records each empty value. +Custom actions: requires `custom_text` to be a non-empty string. + +Each violation is appended to `metadata.missing_required_fields` for the +REST handler to surface as a structured 422. +""" + +import logging +from typing import Any + +from src.compat.framework_observer import event + +from src.agent.state.agent_state import AgentState, set_error, update_state_metadata +from src.agent.state.step_helpers_emulator import set_current_step +from src.agent.state.steps_emulator import EmulatorGraphStep +from src.utils.ics_collector import build_ic_payload +from src.utils.observer import trace_node + +logger = logging.getLogger(__name__) + + +def _is_value_empty(value: Any) -> bool: + if value is None: + return True + if isinstance(value, str): + return not value.strip() + if isinstance(value, (list, dict)): + return len(value) == 0 + return False + + +def _validate_answer_node(name: str, node: dict, path: str, missing: list[dict]) -> None: + """An AnswerNode requires `value`. When `value` names a sub-form, the + sub-form lives under a key equal to the value — recurse into it. + """ + + if not isinstance(node, dict): + return + + value = node.get("value") + node_path = f"{path}.{name}" + + if _is_value_empty(value): + missing.append({ + "path": node_path, + "field_name": name, + "kind": "answer", + "reason": "value not filled", + }) + return + + if not isinstance(value, str): + return + + sub_form = node.get(value) + if not isinstance(sub_form, dict): + return + + for sub_name, sub_node in sub_form.items(): + _validate_answer_node(sub_name, sub_node, f"{node_path}={value}", missing) + + +def _validate_predefined_action(action: dict, missing: list[dict]) -> None: + action_id = action.get("action_id") or "<no id>" + action_title = action.get("action_title") or "<no title>" + root = f"{action_title} ({action_id})" + + answers = action.get("answers") or {} + if not isinstance(answers, dict): + return + + for q_name, q_node in answers.items(): + _validate_answer_node(q_name, q_node, root, missing) + + +def _validate_custom_action(action: dict, index: int, missing: list[dict]) -> None: + if _is_value_empty(action.get("custom_text")): + missing.append({ + "path": f"selected_actions[{index}].custom_text", + "field_name": "custom_text", + "kind": "custom", + "reason": "custom_text not filled", + }) + + +def _validate_action(action: dict, index: int, missing: list[dict]) -> None: + if not isinstance(action, dict): + return + if action.get("type") == "custom": + _validate_custom_action(action, index, missing) + else: + _validate_predefined_action(action, missing) + + +@trace_node +async def validate_actions(state: AgentState) -> AgentState: + """Blocks the graph when answer nodes carry empty values.""" + + await set_current_step(state, EmulatorGraphStep.VALIDATE_ACTIONS) + session_id = state.get("session_id", "") + + metadata = state.get("metadata", {}) or {} + selected_actions = metadata.get("selected_actions") or [] + + if not selected_actions: + msg = "selected_actions missing or empty — no actions to generate response from" + logger.error(msg) + new_state = update_state_metadata( + state, + missing_required_fields=[{ + "path": "selected_actions", + "field_name": "selected_actions", + "kind": "list", + "reason": "no action selected", + }], + ) + event("AGA.023", { + "status": "Houve registro do envio de dados para resposta no Emulador: não — selected_actions vazio", + "type": "FAILURE", + "session_id": session_id, + "tag": "AGA.023", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.023"), + }) + return set_error( + new_state, + "MissingRequiredFieldsError", + msg, + step=EmulatorGraphStep.VALIDATE_ACTIONS_FAILED, + ) + + missing: list[dict] = [] + for index, action in enumerate(selected_actions): + _validate_action(action, index, missing) + + if missing: + logger.warning( + "Required fields not filled | count=%d | paths=%s", + len(missing), + [m["path"] for m in missing], + ) + new_state = update_state_metadata(state, missing_required_fields=missing) + event("AGA.023", { + "status": f"Houve registro do envio de dados para resposta no Emulador: não — {len(missing)} campo(s) obrigatório(s) faltando", + "type": "FAILURE", + "session_id": session_id, + "tag": "AGA.023", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.023"), + }) + return set_error( + new_state, + "MissingRequiredFieldsError", + f"{len(missing)} required field(s) not filled", + step=EmulatorGraphStep.VALIDATE_ACTIONS_FAILED, + ) + + logger.info("Required-field validation OK | actions=%d", len(selected_actions)) + event("AGA.022", { + "status": "Houve registro do envio de dados para resposta no Emulador: sim", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.022", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.022"), + }) + return update_state_metadata(state, missing_required_fields=[]) + + +def should_continue(state: AgentState) -> str: + if state.get("error"): + return "failed" + return "continue" diff --git a/src/agent/nodes/emulator/validate_response_node.py b/src/agent/nodes/emulator/validate_response_node.py new file mode 100644 index 0000000..9013406 --- /dev/null +++ b/src/agent/nodes/emulator/validate_response_node.py @@ -0,0 +1,160 @@ +"""Structural validation of the generated response + feedback history append. + +1. Structural checks (protocol present, word count, no markdown) → `metadata.validation`. +2. On `type=regenerate`, appends `{response, feedback, registered_at}` to + `processing.feedback_history` so future regenerations have full context. + +Validation errors do NOT block the graph — the operator must still see the +response to review; only sub-status and telemetry are marked. +""" + +import logging +import re +from datetime import datetime, timezone + +from src.agent.local_prompts.emulator.response_emulator_generation import ( + OUVIDORIA_DEFAULT, + OUVIDORIA_POSPAGO, +) +from src.agent.state.agent_state import AgentState, update_state_metadata +from src.agent.state.step_helpers_emulator import set_current_step +from src.agent.state.steps_emulator import EmulatorGraphStep +from src.core.config import settings +from src.infrastructure.oci.autonomous.connection import db_manager +from src.utils.observer import trace_node + +logger = logging.getLogger(__name__) + +_MIN_WORDS = 60 +# Upper bound covers the body (~220 words per the prompt) + mandatory +# Ouvidoria block (~65 words on pós-pago, ~45 on default). +_MAX_WORDS = 360 +_MARKDOWN_HINTS = ("**", "##", "- ", "* ", "```", "[", "]") + + +def _count_words(text: str) -> int: + return len(re.findall(r"\b\w+\b", text or "")) + + +def _has_markdown_residue(text: str) -> bool: + return any(hint in text for hint in _MARKDOWN_HINTS) + + +async def _register_feedback_if_regenerate(state: AgentState) -> bool: + """On `type=regenerate`, appends the rejected pair to feedback_history. + + Failures are tolerant — only logged as warnings. Returns True if registered. + """ + metadata = state.get("metadata", {}) or {} + request_context = metadata.get("request_context") or {} + if request_context.get("type") != "regenerate": + return False + + previous_response = request_context.get("previous_response") + feedback = request_context.get("feedback") or {} + transaction_id = metadata.get("transaction_id") + + if not previous_response or not feedback or not transaction_id: + logger.warning( + "regenerate missing previous_response/feedback/transaction_id — skipping" + ) + return False + + if metadata.get("dry_run"): + logger.info("dry_run=True — skipping feedback_history push") + return True + + if db_manager.db is None: + logger.warning("Autonomous DB unavailable — skipping feedback_history push") + return False + + entry = { + "response": previous_response, + "feedback": feedback, + "registered_at": datetime.now(timezone.utc), + } + try: + coll = db_manager.db[settings.AUTONOMOUS_NOSQL_COLLECTION] + result = await coll.update_one( + {"transactionId": transaction_id}, + {"$push": {"processing.feedback_history": entry}}, + ) + logger.info( + "feedback_history updated | transaction_id=%s | matched=%s | modified=%s", + transaction_id, result.matched_count, result.modified_count, + ) + return True + except Exception as exc: + logger.warning("Failed to persist feedback in DB: %s", exc, exc_info=True) + return False + + +def _evaluate_structure(case_response: str, protocol: str) -> tuple[list[str], list[str]]: + """Runs deterministic structural checks; returns `(errors, warnings)`.""" + errors: list[str] = [] + warnings: list[str] = [] + + if not case_response: + errors.append("case_response_empty") + return errors, warnings + + if protocol and protocol not in case_response: + errors.append("missing_complaint_protocol") + + # Opening + ombudsman are assembled deterministically in + # generate_response_node, so these warnings should never fire in practice — + # they are a safety net that flags a regression in the assembly as telemetry, + # without blocking the draft (operator still reviews it). + if not case_response.startswith("Olá, "): + warnings.append("opening_block_missing") + if OUVIDORIA_POSPAGO not in case_response and OUVIDORIA_DEFAULT not in case_response: + warnings.append("ombudsman_block_missing") + + word_count = _count_words(case_response) + if word_count < _MIN_WORDS: + warnings.append(f"too_short:{word_count}") + elif word_count > _MAX_WORDS: + warnings.append(f"too_long:{word_count}") + + if _has_markdown_residue(case_response): + warnings.append("markdown_residue") + + return errors, warnings + + +@trace_node +async def validate_response(state: AgentState) -> AgentState: + """Validates structure and (on regenerate) appends the rejected pair to history.""" + + await set_current_step(state, EmulatorGraphStep.VALIDATE_RESPONSE) + session_id = state.get("session_id", "") + + feedback_registered = await _register_feedback_if_regenerate(state) + + metadata = state.get("metadata", {}) or {} + case_response = (metadata.get("case_response") or "").strip() + case_data = metadata.get("case_data") or {} + complaint = case_data.get("complaint") or {} + protocol = (complaint.get("complaintProtocol") or "").strip() + + errors, warnings = _evaluate_structure(case_response, protocol) + passed = not errors + + validation = { + "passed": passed, + "errors": errors, + "warnings": warnings, + "feedback_registered": feedback_registered, + } + + logger.info( + "Validation result | passed=%s | errors=%s | warnings=%s | feedback_registered=%s", + passed, errors, warnings, feedback_registered, + ) + + new_state = update_state_metadata(state, validation=validation) + if passed: + await set_current_step(new_state, EmulatorGraphStep.VALIDATE_RESPONSE_PASSED) + else: + await set_current_step(new_state, EmulatorGraphStep.VALIDATE_RESPONSE_FAILED) + return new_state diff --git a/src/agent/nodes/fetch_ticket_node.py b/src/agent/nodes/fetch_ticket_node.py new file mode 100644 index 0000000..5d5574b --- /dev/null +++ b/src/agent/nodes/fetch_ticket_node.py @@ -0,0 +1,58 @@ +""" +Node for fetching Anatel tickets from CMS. +""" + +import logging +from src.agent.state.agent_state import AgentState, set_error +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.utils.observer import trace_node +from src.compat.framework_observer import event +from src.utils.ics_collector import build_anatel_entry_fields, build_ic_payload, build_noc_metadata + +logger = logging.getLogger(__name__) + +@trace_node +async def fetch_ticket_data(state: AgentState) -> AgentState: + """ + Ensures ticket data is present in the context. + + This node validates the context and standardizes the state. + """ + logger.info( + "Running fetch_ticket_node") + + await set_current_step(state, GraphStep.FETCHING_TICKET) + session_id = state.get("session_id", "") + + event("NOC.001", { + "status": "Agent initialized - ready to process", + "type": "INFO", + **build_noc_metadata(state, "NOC.001"), + }, metadata={"noc": True}) + + event("AGA.001", { + "status": "Entrada do Agente: chamados entrantes para o Agente", + "type": "INFO", + "session_id": session_id, + "tag": "AGA.001", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.001", build_anatel_entry_fields(state)), + }, metadata={"noc": True}) + + metadata = state.get("metadata", {}) + context = metadata.get("request_context") + + if not context: + logger.error("No ticket data found in request_context") + await set_current_step(state, GraphStep.FETCHING_TICKET_FAILED) + return set_error( + state, + "ContextError", + "No ticket data found in request_context. Evaluation cannot proceed.", + step=GraphStep.FETCHING_TICKET_FAILED, + ) + + logger.info("Ticket data verified in context") + return state diff --git a/src/agent/nodes/identity_verification_node.py b/src/agent/nodes/identity_verification_node.py new file mode 100644 index 0000000..577aa95 --- /dev/null +++ b/src/agent/nodes/identity_verification_node.py @@ -0,0 +1,186 @@ +import logging +from src.agent.state.agent_state import AgentState, update_state_metadata, set_error +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.utils.decision_helpers import ( + CANCEL_REASON_UNAUTHORIZED_THIRD_PARTY, + apply_cancellation_triplet, + apply_treatment_triplet, + build_canceling_decision, +) +from src.utils.ics_collector import build_ic_payload +from src.utils.observer import trace_node +from src.compat.framework_observer import event + +logger = logging.getLogger(__name__) + + +# Possible decisions emitted by this node. +IDENTITY_DECISION_PROCEED = "proceed" +IDENTITY_DECISION_SMART_HUMAN = "smart_human" +IDENTITY_DECISION_CANCEL = "cancel" + + +def _normalize_cpf(value: str | None) -> str | None: + if value is None: + return None + digits = "".join(ch for ch in value if ch.isdigit()) + return digits or None + + +def _is_third_party_complaint(customer: dict, imdb_data: dict) -> tuple[bool, str | None]: + """Third-party when complainant CPF diverges from subscriber or IMDB `socialSecNo` (only on 200).""" + customer_cpf = _normalize_cpf(customer.get("cpfCnpj")) + subscriber_cpf = _normalize_cpf((customer.get("subscriber") or {}).get("cpfCnpj")) + + if customer_cpf and subscriber_cpf and customer_cpf != subscriber_cpf: + return True, "subscriber" + + if imdb_data.get("status_code") == 200: + imdb_cpf = _normalize_cpf(imdb_data.get("socialSecNo")) + if customer_cpf and imdb_cpf and customer_cpf != imdb_cpf: + return True, "imdb" + + return False, None + + +def _authorized_via_gov_br_seal(customer: dict) -> bool: + """Authenticated when `govBrSeal` is non-empty.""" + seal = customer.get("govBrSeal") + if seal is None: + return False + if isinstance(seal, bool): + return seal + if isinstance(seal, str): + return seal.strip() != "" + return bool(seal) + + +def _authorized_via_attachment(customer: dict) -> bool: + """True when `customer.hasAttachment` is True (assumed power-of-attorney; agent never sees the file).""" + return customer.get("hasAttachment") is True + + +@trace_node +async def perform_identity_verification(state: AgentState) -> AgentState: + """Pre-LLM deterministic identity check: proceed | Smart Human | cancel.""" + try: + session_id = state.get("session_id", "") + causa_raiz = state.get("metadata", {}).get("request_context", {}).get("speech_analytics", {}).get("causa_raiz", "N/A") + logger.info("Running identity verification node") + await set_current_step(state, GraphStep.IDENTITY_VERIFICATION) + + context = state.get("metadata", {}).get("request_context", {}) + if not context: + return set_error( + state, + "ValidationError", + "Missing request_context data in metadata", + step=GraphStep.IDENTITY_VERIFICATION, + ) + + customer = context.get("customer", {}) or {} + imdb_data = context.get("imdb_access_data", {}) or {} + + third_party, divergence_source = _is_third_party_complaint(customer, imdb_data) + gov_br_authorized = _authorized_via_gov_br_seal(customer) + attachment_present = _authorized_via_attachment(customer) + + if not third_party: + decision = IDENTITY_DECISION_PROCEED + reasoning = "Reclamação pelo titular (CPFs coerentes) — seguindo análise." + elif gov_br_authorized: + decision = IDENTITY_DECISION_SMART_HUMAN + reasoning = ( + f"Reclamação por terceiro (CPF divergente vs {divergence_source}) " + f"autenticada via Selo GOV BR — encaminhar para Smart Human." + ) + elif attachment_present: + decision = IDENTITY_DECISION_SMART_HUMAN + reasoning = ( + f"Reclamação por terceiro (CPF divergente vs {divergence_source}) " + f"sem Selo GOV BR; anexo presente — encaminhar para Smart Human " + f"para validação da Procuração." + ) + else: + decision = IDENTITY_DECISION_CANCEL + reasoning = ( + f"Reclamação por terceiro não autorizado (CPF divergente vs " + f"{divergence_source}, sem Selo GOV BR e sem anexo) — " + f"solicitando cancelamento da ID Anatel." + ) + + logger.info(f"Identity verification decision: {decision} | {reasoning}") + + context["identity_verification"] = { + "decision": decision, + "third_party_complaint": third_party, + "divergence_source": divergence_source, + "authorized_via_gov_br_seal": gov_br_authorized, + "authorized_via_attachment": attachment_present, + "reasoning": reasoning, + } + + if decision == IDENTITY_DECISION_CANCEL: + event("AGA.002", { + "description": "Identity verification → cancel ID Anatel (terceiro não autorizado)", + "step": "identity_verification", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.002", + **build_ic_payload(state, "AGA.002", { + "agentSpecificData.statusCode": decision, + "intention": causa_raiz, + }), + }) + event("AGA.032", { + "description": f"Validação do chamado: Cancelar por CPF Divergente (vs {divergence_source})", + "step": "identity_verification", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.032", + **build_ic_payload(state, "AGA.032", { + "agentSpecificData.statusCode": decision, + "intention": causa_raiz, + }), + }) + context["cancel_reason"] = CANCEL_REASON_UNAUTHORIZED_THIRD_PARTY + context["canceling_reasoning"] = reasoning + context["canceling_decision"] = build_canceling_decision( + "cancelar", reasoning, cancel_reason=CANCEL_REASON_UNAUTHORIZED_THIRD_PARTY + ) + context = apply_cancellation_triplet(context) + await set_current_step(state, GraphStep.CANCELING_ANALYSIS_CANCEL_TICKET) + state = update_state_metadata(state, request_context=context) + elif decision == IDENTITY_DECISION_SMART_HUMAN: + auth_via = "gov_br_seal" if gov_br_authorized else "attachment" + context["smart_human_handling"] = True + context["smart_human_reason"] = reasoning + # Skips canceling/reclassification (which normally set the triplet); without it Pydantic rejects the Siebel SR. + context = apply_treatment_triplet(context) + state = update_state_metadata(state, request_context=context) + else: + state = update_state_metadata(state, request_context=context) + + return state + + except Exception as e: + error_type = str(type(e).__name__) + msg = f"{error_type} at identity_verification_node: {e}" + logger.error(msg, exc_info=True) + state["final_response"] = msg + return set_error(state, error_type, msg, step=GraphStep.IDENTITY_VERIFICATION) + + +def route_after_identity_verification(state: AgentState) -> str: + """Maps the identity decision to the next graph node.""" + context = state.get("metadata", {}).get("request_context", {}) + decision = (context.get("identity_verification") or {}).get("decision") + + if decision == IDENTITY_DECISION_CANCEL: + return "cancel" + if decision == IDENTITY_DECISION_SMART_HUMAN: + return "smart_human" + if decision == IDENTITY_DECISION_PROCEED: + return "proceed" + return "failed" diff --git a/src/agent/nodes/imdb_enrichment_node.py b/src/agent/nodes/imdb_enrichment_node.py new file mode 100644 index 0000000..293e181 --- /dev/null +++ b/src/agent/nodes/imdb_enrichment_node.py @@ -0,0 +1,189 @@ +import logging +from src.agent.state.agent_state import AgentState, set_error, update_state_metadata +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.components.clients.imdb_client import ImdbClient +from src.api.schemas.imdb_schemas import ImdbRequest +from src.core.config import settings +from src.components.clients.exceptions.imdb_exceptions import ImdbClientError +from src.utils.observer import trace_node +from src.utils.ics_collector import build_ic_payload +from src.compat.framework_observer import event +import json + +logger = logging.getLogger(__name__) + +@trace_node +async def imdb_enrich_ticket(state: AgentState) -> AgentState: + """ + Fetches access data in the IMDB API to enrich call data. + """ + client = ImdbClient() + + await set_current_step(state, GraphStep.IMDB_ENRICHMENT) + session_id = state.get("session_id", "") + + logger.info( + f"Running IMDB enrichment node. Current step: {state['current_step']}") + + context = state.get("metadata", {}).get("request_context", {}) + + # Check if data is already present (cached) + if "imdb_access_data" in context: + logger.info( + "IMDB access data already exists in context (cached). Skipping API call.") + await set_current_step(state, GraphStep.IMDB_ENRICHMENT_COMPLETED) + return state + + if not context: + msg = "No request_context found in metadata for enrichment" + state["final_response"] = msg + logger.error( + msg) + await set_current_step(state, GraphStep.IMDB_ENRICHMENT_FAILED) + return set_error(state, "ValidationError", "Missing request_context data in metadata", step=GraphStep.IMDB_ENRICHMENT_FAILED) + + client_id: str = settings.PMID_API_CLIENT_ID + + customer = context.get("customer", {}) + # Refactored for Swagger v0.0.5: cpfCnpj and msisdn + cpf_cnpj: str = customer.get("cpfCnpj") + msisdn: str = customer.get("msisdn") + + if not msisdn: + logger.warning("MSISDN not found in the request. Ticket must be handled by smart human") + event("AGA.011", { + "status": "Resultado IMDB: MSISDN ausente, redirecionando para atendimento", + "type": "INFO", + "session_id": session_id, + "tag": "AGA.011", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.011"), + }, metadata={"noc": True}) + context["smart_human_handling"] = True + state = update_state_metadata(state, request_context=context) + await set_current_step(state, GraphStep.IMDB_ENRICHMENT_COMPLETED) + return state + + if not all([cpf_cnpj, client_id]): + msg = f"Missing one of the required fields cpfCnpj={bool(cpf_cnpj)} or client_id={bool(client_id)} to fetch access data in the IMDB API" + state["final_response"] = msg + logger.error( + msg) + await set_current_step(state, GraphStep.IMDB_ENRICHMENT_FAILED) + return set_error(state, "ValidationError", "Missing required fields to fetch access data in the PMID API", step=GraphStep.IMDB_ENRICHMENT_FAILED) + + # Validate parameters via DTO before forwarding to the client + imdb_request = ImdbRequest(msisdn=msisdn, client_id=client_id) + + logger.info( + "Fetching IMDB API") + try: + response, http_meta = await client.get_imdb_access_data_with_retry( + msisdn=imdb_request.msisdn, + client_id=imdb_request.client_id, + max_retries=2 + ) + + if response is None: + # 204: number is not TIM active + logger.warning( + "IMDB API returned 204 (no TIM number). Signaling state for redirection.") + + context["imdb_access_data"] = { + "status_code": 204, + "cpf_cnpj": cpf_cnpj, + "plan": None, + "statusType": None, + "statusDescription": None + } + + event("AGA.011", { + "status": "IMDB retornou 204 (número não-TIM ou inativo)", + "type": "INFO", + "session_id": session_id, + "tag": "AGA.011", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.011", { + "apiUrl": http_meta["url"], + "apiStatusCode": http_meta["status_code"], + "apiResponsePayload": http_meta["response_text"], + "latencyMs": http_meta["latency_ms"], + }), + }, metadata={"noc": True}) + + else: + # Get current context and update it with the retrieved data + # We create a new dict to follow the non-mutating pattern + context["imdb_access_data"] = { + "status_code": 200, + "cpf_cnpj": cpf_cnpj, + "latency_ms": http_meta["latency_ms"], + "url": http_meta["url"], + "retry_count": http_meta.get("retry_count", 0), + **response.model_dump() + } + + event("AGA.011", { + "status": "Resultado IMDB: Sucesso - dados obtidos", + "type": "INFO", + "session_id": session_id, + "tag": "AGA.011", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.011", { + "apiUrl": http_meta["url"], + "apiStatusCode": http_meta["status_code"], + "apiResponsePayload": http_meta["response_text"], + "latencyMs": http_meta["latency_ms"], + }), + }, metadata={"noc": True}) + + logger.debug( + f"IMDB Access data retrieved: {json.dumps(context['imdb_access_data'], indent=4, ensure_ascii=False)}\n", + ) + + # Update state with the modified context via existing update_state_metadata + state = update_state_metadata(state, request_context=context) + await set_current_step(state, GraphStep.IMDB_ENRICHMENT_COMPLETED) + + logger.info( + f"IMDB Access data retrieved successfully. State metadata and current step updated: {state['current_step']}\n") + return state + + except ImdbClientError as client_exc: + error_type = str(type(client_exc).__name__) + await set_current_step(state, GraphStep.IMDB_ENRICHMENT_FAILED) + state["final_response"] = f"Failed to fetch IMDB access data: {str(client_exc)}" + logger.error( + f"{error_type} at IMDB enrichment node: {str(client_exc)}", exc_info=True) + + # Extract HTTP metadata from exception + api_fields = { + "apiUrl": getattr(client_exc, "url", None) or settings.PMID_API_HOST, + "apiStatusCode": getattr(client_exc, "status_code", None) if hasattr(client_exc, "status_code") and getattr(client_exc, "status_code", None) is not None else "N/A", + "apiResponsePayload": getattr(client_exc, "response_text", None) if hasattr(client_exc, "response_text") and getattr(client_exc, "response_text", None) is not None else "N/A", + "latencyMs": getattr(client_exc, "latency_ms", None) if getattr(client_exc, "latency_ms", None) is not None else "N/A", + } + + event("AGA.028", { + "status": f"Insucesso consulta dados no IMDB - {error_type}: {str(client_exc)}", + "type": "FAILURE", + "session_id": session_id, + "tag": "AGA.028", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.028", api_fields), + }, metadata={"noc": True}) + return set_error(state, str(type(client_exc).__name__), str(client_exc), step=GraphStep.IMDB_ENRICHMENT_FAILED) + + +def should_continue(state: AgentState) -> str: + """ + Router condition to decide whether to continue the graph after validation. + """ + if state.get("current_step") == GraphStep.IMDB_ENRICHMENT_COMPLETED: + return "continue" + return "failed" diff --git a/src/agent/nodes/knowledge_base_enrichment_node.py b/src/agent/nodes/knowledge_base_enrichment_node.py new file mode 100644 index 0000000..89a8976 --- /dev/null +++ b/src/agent/nodes/knowledge_base_enrichment_node.py @@ -0,0 +1,405 @@ +import logging + +from src.agent.state.agent_state import AgentState, update_state_metadata +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.components.clients.tais_kb_client import TaisKbClient, Product +from src.components.clients.exceptions.tais_kb_exceptions import TaisKbClientError +from src.core.config import settings +from src.utils.observer import trace_node +from src.utils.ics_collector import build_ic_payload, build_rag_telemetry_fields, build_noc_db_metadata +from src.compat.framework_observer import event + +logger = logging.getLogger(__name__) + +_NOT_FOUND_MESSAGE = "Procedimentos não encontrado" +_UNAVAILABLE_MESSAGE = "Indisponível consulta de Procedimento - realizar consulta manualmente" + +# Mensagens que speech_enrichment_node escreve em reclamacao_resumo quando +# o Speech está indisponível — não devem entrar no embedding da KB. +_SPEECH_PLACEHOLDER_PREFIXES = ( + "Indisponível SPEECH", + "Reclamação Atual não foi encontrada", +) + + +def _is_speech_placeholder(value: str | None) -> bool: + return bool(value) and value.startswith(_SPEECH_PLACEHOLDER_PREFIXES) + + +def _coerce_str(value) -> str | None: + if value is None: + return None + inner = getattr(value, "value", value) + text = str(inner).strip() + return text or None + + +def _extract_plan_info(imdb_access_data: dict) -> tuple[str | None, str | None, str | None]: + """Returns (plan_text, plan_type, commercial_name) extracted from imdb_access_data.plan.""" + plan = imdb_access_data.get("plan") + if not plan: + return None, None, None + + if isinstance(plan, str): + return plan, plan, None + + if isinstance(plan, dict): + commercial_name = plan.get("commercialName") or plan.get("name") or plan.get("Name") + plan_text = ( + commercial_name + or plan.get("Description") + or plan.get("description") + ) + plan_type = plan.get("Type") or plan.get("type") + return plan_text, plan_type, commercial_name + + return None, None, None + + +def _map_plan_type_to_segment_and_subsegment( + plan_type: str | None, + commercial_name: str | None, +) -> tuple[str | None, str | None]: + """Maps plan.Type + commercialName to (kb_segment, kb_sub_segment). + + Translates IMDB plan type values (e.g. "Pós-pago", "Express") to the + lowercase segment keys expected by the TAIS KB search API, and derives + the appropriate sub-segment filter for each case. + + Returns (None, None) for plan types that should not filter by segment + (e.g. SMB, Wi-Fi Carros, Controle Flex). + """ + if not plan_type: + return None, None + + t = plan_type.strip().lower() + cn = (commercial_name or "").lower() + + if t == "controle": + return "controle", "Fatura" + if t == "pós-pago": + return "pospago", "Fatura" + if t == "pré-pago": + return ("beta", None) if "beta" in cn else ("prepago", None) + if t == "express": + return ("controle", "Express") if "controle" in cn else ("pospago", "Express") + if t == "digital": + return "pospago", "Fatura" + # SMB, Wi-Fi Carros, Controle Flex, etc. — sem filtro de segmento + return None, None + + +def _build_query_payload(context: dict) -> tuple[str, str | None, str | None]: + """Builds the KB query text, segment, and sub_segment from context. + + Query source (controlled by TAIS_KB_USE_SPEECH_SUMMARY): + - False (default): complaint.description, fallback to speech.reclamacao_resumo + - True: speech.reclamacao_resumo, fallback to complaint.description + + Returns (query_text, kb_segment, kb_sub_segment). + """ + speech = context.get("speech_analytics") or {} + imdb = context.get("imdb_access_data") or {} + complaint = context.get("complaint") or {} + + _, plan_type, commercial_name = _extract_plan_info(imdb) + segment, sub_segment = _map_plan_type_to_segment_and_subsegment(plan_type, commercial_name) + + if settings.TAIS_KB_USE_SPEECH_SUMMARY: + resumo = _coerce_str(speech.get("reclamacao_resumo")) + if resumo and not _is_speech_placeholder(resumo): + query_text = resumo + else: + query_text = _coerce_str(complaint.get("description")) or "" + else: + query_text = _coerce_str(complaint.get("description")) or "" + if not query_text: + resumo = _coerce_str(speech.get("reclamacao_resumo")) + if resumo and not _is_speech_placeholder(resumo): + query_text = resumo + + return query_text, segment, sub_segment + + +def _not_found_payload(query_text: str) -> dict: + return { + "query": query_text, + "documents": [], + "message": _NOT_FOUND_MESSAGE, + } + + +def _unavailable_payload(query_text: str, error: str) -> dict: + return { + "query": query_text, + "documents": [], + "message": _UNAVAILABLE_MESSAGE, + "_error": error, + } + + +@trace_node +async def enrich_with_knowledge_base(state: AgentState) -> AgentState: + """ + Node that enriches the ticket with relevant procedures from the TAIS knowledge base. + + Reads from state.metadata.request_context: + - complaint.description (primary query source) + - speech_analytics.reclamacao_resumo (query source when TAIS_KB_USE_SPEECH_SUMMARY=True) + - imdb_access_data.plan.{Type, commercialName} (segment/sub_segment derivation) + + Writes to state.metadata.request_context.relevant_documents: + {"query": str, "documents": [{documentId, title, chunk, distance}, ...], "message"?: str} + + Failures are swallowed gracefully: empty results register "Procedimentos + não encontrado"; client/unexpected errors register "Indisponível consulta + de Procedimento - realizar consulta manualmente". Flow always continues. + """ + await set_current_step(state, GraphStep.KNOWLEDGE_BASE_ENRICHMENT) + session_id = state.get("session_id", "") + + logger.info( + "Running knowledge base enrichment node." + ) + + context = state.get("metadata", {}).get("request_context", {}) + + if "relevant_documents" in context: + logger.info( + "Relevant documents already exist in context (cached). Skipping TAIS KB call." + ) + # Cache only persists the docs that were used by the agent (top_k after + # dedup); the raw RAG retrieval isn't cached. Reuse the cached set as + # both retrieved and selected — we lose the broader retrieved scope + # but observability still gets the doc IDs the agent worked with. + cached_docs = (context.get("relevant_documents") or {}).get("documents") or [] + event("AGA.012", { + "status": "Resultado da Base de Conhecimento: ignorado — documentos já em cache", + "type": "INFO", + "session_id": session_id, + "tag": "AGA.012", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.012", { + "apiUrl": settings.TAIS_GENAI_ENDPOINT or "N/A", + "apiStatusCode": "N/A", + "apiResponsePayload": "N/A", + "latencyMs": "N/A", + **build_rag_telemetry_fields(retrieved=cached_docs, selected=cached_docs), + }), + }, metadata={"noc": True}) + return state + + query_text, segment, sub_segment = _build_query_payload(context) + + if not query_text.strip(): + logger.warning( + "Knowledge base enrichment skipped: empty query payload (no complaint description or speech summary)." + ) + event("AGA.012", { + "status": "Resultado da Base de Conhecimento: ignorado — query vazio", + "type": "INFO", + "session_id": session_id, + "tag": "AGA.012", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.012", { + "apiUrl": settings.TAIS_GENAI_ENDPOINT or "N/A", + "apiStatusCode": "N/A", + "apiResponsePayload": "N/A", + "latencyMs": "N/A", + **build_rag_telemetry_fields([]), + }), + }, metadata={"noc": True}) + context = dict(context) + context["relevant_documents"] = _not_found_payload(query_text) + state = update_state_metadata(state, request_context=context) + return state + + try: + event("AGA.020", { + "status": "Registro da busca do template: iniciando busca na Base de Conhecimento", + "type": "START", + "session_id": session_id, + "tag": "AGA.020", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.020", build_rag_telemetry_fields([])), + }, metadata={"noc": True}) + + client = TaisKbClient() + search_response = await client.search_documents( + query_text=query_text, + product=Product.MOVEL, + segments=[segment] if segment else None, + sub_segments=[sub_segment] if sub_segment else None, + top_k=settings.TAIS_TOP_K, + check_expiration_date=True, + preprocess=settings.TAIS_KB_PREPROCESS, + postprocess=settings.TAIS_KB_POSTPROCESS, + ) + + http_meta = search_response.get("http_meta") or {} + event("NOC.003", { + "status": "TAIS KB search executed successfully", + "type": "INFO", + **build_noc_db_metadata( + state, "NOC.003", + latency_ms=int(http_meta.get("latency_ms", 0)), + resource_name=settings.TAIS_TABLE_CHUNKS, + ), + }, metadata={"noc": True}) + + documents = [ + { + "documentId": r.get("id_proc"), + "title": r.get("title_proc"), + "chunk": r.get("content"), + "distance": float(r["distance"]) if r.get("distance") is not None else None, + } + for r in search_response["results"] + ] + + api_fields = { + "apiUrl": settings.TAIS_GENAI_ENDPOINT or "N/A", + "apiStatusCode": "200", + "apiResponsePayload": f"{len(documents)} document(s)", + "latencyMs": "N/A", + } + + if documents: + relevant_documents: dict = { + "query": query_text, + "documents": documents, + } + postprocessing_content = search_response.get("postprocessing_content") + if postprocessing_content: + relevant_documents["message"] = postprocessing_content + postprocessing_id_procs_map = search_response.get("postprocessing_id_procs_map") + if postprocessing_id_procs_map: + relevant_documents["postprocessing_id_procs_map"] = postprocessing_id_procs_map + + logger.info( + f"TAIS KB returned {len(documents)} document(s)." + ) + retrieved_docs = search_response.get("retrieved_documents") or [] + rag_fields = build_rag_telemetry_fields(retrieved=retrieved_docs, selected=documents) + event("AGA.021", { + "status": f"Quantidade de templates retornados: {len(documents)} (RAG retrieved: {len(retrieved_docs)})", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.021", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.021", rag_fields), + }) + event("AGA.012", { + "status": f"Resultado da Base de Conhecimento: sucesso — {len(documents)} documento(s) encontrado(s)", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.012", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.012", {**api_fields, **rag_fields}), + }, metadata={"noc": True}) + else: + relevant_documents = _not_found_payload(query_text) + logger.info( + "TAIS KB returned no documents." + ) + # Even when zero docs reach the agent (top_k=3 empty), the RAG + # may have retrieved candidates that all failed dedup or some + # downstream filter — expose them for observability. + retrieved_docs = search_response.get("retrieved_documents") or [] + rag_fields = build_rag_telemetry_fields(retrieved=retrieved_docs, selected=[]) + event("AGA.021", { + "status": "Quantidade de templates retornados: 0", + "type": "INFO", + "session_id": session_id, + "tag": "AGA.021", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.021", rag_fields), + }) + event("AGA.012", { + "status": "Resultado da Base de Conhecimento: nenhum documento encontrado", + "type": "INFO", + "session_id": session_id, + "tag": "AGA.012", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.012", {**api_fields, **rag_fields}), + }, metadata={"noc": True}) + + if search_response.get("postprocessing_succeeded") is False: + pp_meta = search_response.get("postprocessing_http_meta") or { + "url": settings.TAIS_GENAI_ENDPOINT or "N/A", + "status_code": "N/A", + "response_text": "N/A", + "latency_ms": 0, + } + failure_reason = search_response.get( + "postprocessing_failure_reason", "motivo desconhecido" + ) + event("AGA.036", { + "status": f"Insucesso Resumo dos procedimentos Tais - {failure_reason}", + "type": "FAILURE", + "session_id": session_id, + "tag": "AGA.036", + "call_id": session_id, + "origin": "LLM", + **build_ic_payload(state, "AGA.036", api_fields), + }) + except TaisKbClientError as exc: + logger.warning( + f"TAIS KB unavailable — continuing without relevant documents. Error: {exc}" + ) + relevant_documents = _unavailable_payload(query_text, error=str(exc)) + await set_current_step(state, GraphStep.KNOWLEDGE_BASE_ENRICHMENT_UNAVAILABLE) + + api_fields = { + "apiUrl": getattr(exc, "url", None) or settings.TAIS_GENAI_ENDPOINT or "N/A", + "apiStatusCode": getattr(exc, "status_code", None) if getattr(exc, "status_code", None) is not None else "N/A", + "apiResponsePayload": getattr(exc, "response_text", None) if getattr(exc, "response_text", None) is not None else "N/A", + "latencyMs": getattr(exc, "latency_ms", None) if getattr(exc, "latency_ms", None) is not None else "N/A", + } + + event("AGA.013", { + "status": f"Insucesso consulta API base de conhecimento - TaisKbClientError: {str(exc)}", + "type": "FAILURE", + "session_id": session_id, + "tag": "AGA.013", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.013", api_fields), + }) + except Exception as exc: + logger.error( + f"Unexpected error in TAIS KB enrichment: {exc}" + , exc_info=True) + relevant_documents = _unavailable_payload(query_text, error=str(exc)) + await set_current_step(state, GraphStep.KNOWLEDGE_BASE_ENRICHMENT_UNAVAILABLE) + + api_fields = { + "apiUrl": settings.TAIS_GENAI_ENDPOINT or "N/A", + "apiStatusCode": "N/A", + "apiResponsePayload": str(exc), + "latencyMs": "N/A", + } + + event("AGA.013", { + "status": f"Insucesso consulta API base de conhecimento - erro inesperado: {str(exc)}", + "type": "FAILURE", + "session_id": session_id, + "tag": "AGA.013", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.013", api_fields), + }) + + context = dict(context) + context["relevant_documents"] = relevant_documents + state = update_state_metadata(state, request_context=context) + + return state diff --git a/src/agent/nodes/reclassification_analysis_node.py b/src/agent/nodes/reclassification_analysis_node.py new file mode 100644 index 0000000..b1f64dc --- /dev/null +++ b/src/agent/nodes/reclassification_analysis_node.py @@ -0,0 +1,263 @@ +import time +import json +import logging +from src.agent.state.agent_state import AgentState, update_state_metadata, set_error +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.core.config import settings +from src.providers.llm_provider import classification_llm, chat_llm_with_usage, LLM_ENDPOINT +from src.utils.observer import trace_node, score_current_trace +from src.compat.framework_observer import event +from src.agent.local_prompts.ticket_reclassification import ticket_reclassification_pt +from src.core.prompt_manager import get_prompt +from src.agent.local_prompts.anatel_motives.anatel_motives import ANATEL_MOTIVES +from src.utils.text import truncate_text +from src.utils.ics_collector import build_ic_payload, build_llm_token_fields, build_noc_llm_metadata, build_noc_metadata +from src.utils.decision_helpers import ( + apply_reclassification_triplet, + apply_treatment_triplet, + build_reclassification_decision, +) +from src.utils.external_response_builder import build_reclassification_external_response + +logger = logging.getLogger(__name__) + +class ReclassificationError(Exception): + pass + +def _filter_categories_by_service(service: str, first_service: str = "") -> list[dict]: + """ + Returns the list of categories for the given service from the ANATEL_MOTIVES dictionary. + Prioritizes the primary service and falls back to the first_service if provided. + """ + # Direct lookup with guaranteed keys from ANATEL_MOTIVES + categories = ANATEL_MOTIVES.get(service) + + # Fallback to first_service if primary service returned nothing and first_service is provided + if not categories and first_service: + categories = ANATEL_MOTIVES.get(first_service) + + return categories or [] + +async def _analyze_reclassification(context: dict, session_id: str = "") -> tuple[dict, dict]: + """ + Calls the LLM with the reclassification prompt. + Evaluates if current modality/motive is correct or if reclassification is needed. + Returns a tuple (parsed_response, llm_meta), where llm_meta carries + the raw LLM content and prompt for LLM-origin IC payload fields. + """ + llm = classification_llm + llm_meta: dict = {"content": None, "prompt": None, "model": str(llm.eligibleModel_name), "latency_ms": 0} + + service = context.get("complaint", {}).get("service", "") + first_service = context.get("complaint", {}).get("first_service", "") + modality = context.get("complaint", {}).get("modality", "") + motive = context.get("complaint", {}).get("motive", "") + description = context.get("complaint", {}).get("description", "") + + # Filter categories for the service + if settings.USE_FULL_ANATEL_DICT: + seen = set() + available_categories = [] + for service_list in ANATEL_MOTIVES.values(): + for cat in service_list: + # Key based on modality and motive for uniqueness + key = (cat["modality"], cat["motive"]) + if key not in seen: + available_categories.append(cat) + seen.add(key) + else: + available_categories = _filter_categories_by_service(service, first_service) + + if not available_categories: + msg = f"No Anatel categories found for service '{service}'." + logger.error(msg) + err = ReclassificationError(msg) + err.llm_meta = llm_meta + raise err + + payload = { + "service": service, + "modality": modality, + "motive": motive, + "description": description, + "available_categories": available_categories, + } + + prompt = get_prompt("ticket_reclassification_pt", ticket_reclassification_pt) + message = f"{prompt}\n\n[Complaint Payload]:\n{json.dumps(payload, ensure_ascii=False)}" + llm_meta["prompt"] = message + + try: + _t0 = time.perf_counter() + llm_resp = chat_llm_with_usage(llm, message, expect_json=True) + content = llm_resp.content + llm_meta["content"] = content + llm_meta["latency_ms"] = int(llm_resp.latency_ms) + llm_meta["model"] = llm_resp.model + llm_meta["prompt_tokens"] = llm_resp.prompt_tokens + llm_meta["completion_tokens"] = llm_resp.completion_tokens + + parsed_response = llm_resp.parsed_json or {} + decision = parsed_response.get("decision", "").lower() + decision_valid = decision in ["reclassificar", "tratamento"] + + score_current_trace(name="reclassification_decision_valid", value=1.0 if decision_valid else 0.0) + + return parsed_response, llm_meta + + except Exception as e: + msg = f"Error calling LLM for reclassification: {e}" + logger.error(msg, exc_info=True) + llm_meta["latency_ms"] = int((time.perf_counter() - _t0) * 1000) + err = ReclassificationError(msg) + err.llm_meta = llm_meta + raise err + +@trace_node +async def perform_reclassification_analysis(state: AgentState) -> AgentState: + """ + Node that abstracts Step 2 (Motive Analysis) and Step 3 (Reclassification) into one. + """ + try: + session_id = state.get("session_id", "") + causa_raiz = state.get("metadata", {}).get("request_context", {}).get("speech_analytics", {}).get("causa_raiz", "N/A") + logger.info("Running reclassification analysis node") + await set_current_step(state, GraphStep.RECLASSIFICATION_ANALYSIS) + + context = state.get("metadata", {}).get("request_context", {}) + original_modality = context.get("complaint", {}).get("modality", "") + original_motive = context.get("complaint", {}).get("motive", "") + + result, llm_meta = await _analyze_reclassification(context, session_id=session_id) + + if not result: + return set_error(state, "LLMError", "Failed to get classification result from LLM", step=GraphStep.RECLASSIFICATION_ANALYSIS_FAILED) + + decision = result.get("decision", "").lower() + reasoning = result.get("reasoning", "") + new_modality = result.get("new_modality") + new_motive = result.get("new_motive") + + truncated_reasoning = truncate_text(reasoning, 400) if reasoning else None + + updated_context = { + **context, + "reclassification_reasoning": truncated_reasoning, + } + + if decision == "tratamento": + logger.info(f"Decision: Tratamento. Reasoning: {reasoning}") + event("AGA.005", { + "status": "Agente decidiu continuar com o tratamento do ticket atual", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.005", + "call_id": session_id, + "origin": "LLM", + "step": "reclassification_analysis", + **build_ic_payload(state, "AGA.005", { + "agentSpecificData.statusCode": decision, + "intention": causa_raiz, + **build_llm_token_fields(llm_meta), + }), + }) + updated_context = apply_treatment_triplet(updated_context) + updated_context["reclassification_decision"] = build_reclassification_decision( + "tratamento", truncated_reasoning + ) + elif decision == "reclassificar" and new_modality and new_motive: + logger.info(f"Decision: Reclassificate to {new_modality} - {new_motive}. Reasoning: {reasoning}") + + updated_context = apply_reclassification_triplet(updated_context) + updated_context["new_modality"] = new_modality + updated_context["new_motive"] = new_motive + updated_context["reclassification_decision"] = build_reclassification_decision( + "reclassificar", + truncated_reasoning, + new_modality=new_modality, + new_motive=new_motive, + ) + updated_context["reclassification_reason"] = build_reclassification_external_response(updated_context) + + event("AGA.003", { + "status": "Agente decidiu que o ticket precisa de reclassificação", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.003", + "call_id": session_id, + "origin": "LLM", + "step": "reclassification_analysis", + **build_ic_payload(state, "AGA.003", { + "agentSpecificData.statusCode": decision, + "intention": causa_raiz, + **build_llm_token_fields(llm_meta), + }), + }, metadata={"noc": True}) + else: + logger.error(f"Invalid decision or missing reclassification data: {decision}") + event( + "NOC.004", + { + "status": "LLM returned invalid reclassification decision", + "type": "FAILURE", + **build_noc_llm_metadata( + state, "NOC.004", + latency_ms=llm_meta.get("latency_ms") or 0, + llm_endpoint=LLM_ENDPOINT, + model_name=llm_meta.get("model") or str(classification_llm.eligibleModel_name), + ), + }, + metadata={"noc": True} + ) + + event( + "NOC.009", + { + "status": "Transfer to human", + "type": "FAILURE", + **build_noc_metadata(state, "NOC.009"), + }, + metadata={"noc": True} + ) + + return set_error(state, "ValidationError", f"Invalid classification decision: {decision}", step=GraphStep.RECLASSIFICATION_ANALYSIS_FAILED) + + state = update_state_metadata(state, request_context=updated_context) + await set_current_step(state, GraphStep.RECLASSIFICATION_ANALYSIS_COMPLETED) + return state + except ReclassificationError as e: + _llm_meta = getattr(e, "llm_meta", {}) + event( + "NOC.004", + { + "status": f"LLM error during reclassification analysis: {e}", + "type": "FAILURE", + **build_noc_llm_metadata( + state, "NOC.004", + latency_ms=_llm_meta.get("latency_ms") or 0, + llm_endpoint=LLM_ENDPOINT, + model_name=_llm_meta.get("model") or str(classification_llm.eligibleModel_name), + ), + }, + metadata={"noc": True} + ) + + event( + "NOC.009", + { + "status": f"Transfer to human: {e}", + "type": "FAILURE", + **build_noc_metadata(state, "NOC.009"), + }, + metadata={"noc": True} + ) + + state["final_response"] = str(e) + return set_error(state, "ReclassificationError", str(e), step=GraphStep.RECLASSIFICATION_ANALYSIS_FAILED) + except Exception as e: + error_type = str(type(e).__name__) + msg = f"{error_type} calling LLM for reclassification: {e}" + state["final_response"] = msg + logger.error(msg, exc_info=True) + return set_error(state, error_type, msg, step=GraphStep.RECLASSIFICATION_ANALYSIS_FAILED) diff --git a/src/agent/nodes/siebel_sr_opening_node.py b/src/agent/nodes/siebel_sr_opening_node.py new file mode 100644 index 0000000..4fec929 --- /dev/null +++ b/src/agent/nodes/siebel_sr_opening_node.py @@ -0,0 +1,358 @@ +from pydantic import ValidationError +from src.core.config import settings +from src.agent.state.agent_state import AgentState, set_error, update_state_metadata +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.components.clients.siebel_client import SiebelClient +from src.components.clients.exceptions.siebel_exceptions import SiebelClientError +from src.api.schemas.siebel_schemas import SiebelSRRequest, SiebelProspectSRRequest +from src.utils.external_response_builder import build_external_response +from src.utils.observer import trace_node +from src.utils.ics_collector import build_ic_payload, build_noc_metadata, build_noc_api_metadata +from src.compat.framework_observer import event +import json +import logging +import time + +logger = logging.getLogger(__name__) + +@trace_node +async def open_siebel_sr(state: AgentState) -> AgentState: + """ + Node that opens a Service Request in Siebel CRM. + + Builds and validates the request payload via SiebelSRRequest before + forwarding to SiebelClient, which only handles the raw HTTP call. + Data is extracted from state["metadata"]["request_context"]. + """ + session_id = state.get("session_id", "") + logger.info( + "Running Siebel SR opening node") + + await set_current_step(state, GraphStep.SIEBEL_SR_OPENING) + default_url = f"{settings.SIEBEL_API_HOST}{settings.SIEBEL_API_ROUTE}" + + # Extract data from context + context = state.get("metadata", {}).get("request_context", {}) + + # Par de tags depende do tipo do SR e do nível (acesso vs cliente): + # - "tratamento" nasce Aberta: AGA.016/017 (SMART HUMAN / Agente Especializado). + # - SR regular (cancelar/reclassificar/reencaminhar) em nível acesso: AGA.006/007. + # - Prospect SR (não-TIM/cancelado) em nível cliente: AGA.008 só na falha. + # Inicializamos com os tags de "acesso"; trocaremos para "cliente" abaixo + # quando soubermos use_prospect (depende do IMDB). + classification = context.get("siebel_action", "tratamento") if context else "tratamento" + is_tratamento = classification == "tratamento" + if is_tratamento: + success_tag = "AGA.016" + failure_tag = "AGA.017" + success_label = "Houve Criação de Chamado Siebel para Tratamento (quando enviado para SMART HUMAN ou Agente Especializado): sim" + failure_label = "Houve Criação de Chamado Siebel para Tratamento (quando enviado para SMART HUMAN ou Agente Especializado): não" + else: + success_tag = "AGA.006" + failure_tag = "AGA.007" + success_label = "Resultado da ação no chamado nivel acesso (Foi aberto no Siebel): sim" + failure_label = "Resultado da ação no chamado nivel acesso (Foi aberto no Siebel): não" + + if not context: + msg = "Missing request_context for Siebel SR Opening." + state["final_response"] = msg + event(failure_tag, { + "status": f"{failure_label} - sem request_context", + "type": "FAILURE", + "session_id": session_id, + "tag": failure_tag, + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, failure_tag, { + "apiUrl": "N/A", + "apiStatusCode": "N/A", + "apiResponsePayload": "N/A", + "latencyMs": "N/A", + }), + }) + + if is_tratamento: + event( + "NOC.009", + { + "status": "Transfer to human", + "type": "FAILURE", + **build_noc_metadata(state, "NOC.009"), + }, + metadata={"noc": True} + ) + + return set_error(state, "ValidationError", msg, step=GraphStep.SIEBEL_SR_OPENING) + + customer = context.get("customer", {}) + cpf = customer.get("cpfCnpj") + msisdn = customer.get("msisdn") + + if not all([cpf, msisdn]): + logger.error(f"Missing required fields for Siebel SR: cpfCnpj={bool(cpf)}, msisdn={bool(msisdn)}") + event(failure_tag, { + "status": f"{failure_label} - CPF ou MSISDN ausentes", + "type": "FAILURE", + "session_id": session_id, + "tag": failure_tag, + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, failure_tag, { + "apiUrl": "N/A", + "apiStatusCode": "N/A", + "apiResponsePayload": "N/A", + "latencyMs": "N/A", + }), + }) + + if is_tratamento: + event( + "NOC.009", + { + "status": "Transfer to human", + "type": "FAILURE", + **build_noc_metadata(state, "NOC.009"), + }, + metadata={"noc": True} + ) + + return set_error(state, "ValidationError", "Missing Customer CPF/CNPJ or MSISDN", step=GraphStep.SIEBEL_SR_OPENING) + + # Decide which Siebel API to target based on the IMDB enrichment result. + # Prospect API handles non-TIM (204) and canceled (200 + statusType=cancelado) numbers. + imdb = context.get("imdb_access_data", {}) or {} + imdb_status_code = imdb.get("status_code") + imdb_status_type = (imdb.get("statusType") or "").lower() + use_prospect = ( + imdb_status_code == 204 + or (imdb_status_code == 200 and imdb_status_type == "cancelado") + ) + + # Prospect SR (não-TIM/cancelado) em fluxo não-tratamento usa "nivel cliente": + # AGA.008 só na falha; sucesso do Prospect não tem AGA dedicado. + if use_prospect and not is_tratamento: + success_tag = None + failure_tag = "AGA.008" + success_label = None + failure_label = "Resultado da ação no chamado nivel cliente (Foi aberto no Siebel): não" + + # Build and validate the Siebel payload via DTO + try: + # IMPORTANT: We use the reasons and notes from the top-level context, + # which were populated by the classification node! + + motivo_extra = build_external_response(context, classification) + + notas = SiebelSRRequest.build_notes( + type = context.get("siebel_action", "tratamento"), + data = context.get("complaint", {}).get("openedAt").isoformat() if context.get("complaint", {}).get("openedAt") else None, + protocolo_anatel = context.get("complaint", {}).get("complaintProtocol"), + acao = context.get("complaint", {}).get("actionType"), + cpf_cliente = cpf, + nome_cliente = customer.get("name"), + telefone_reclamado = msisdn, + telefone_contato = customer.get("phones", [None])[0], + endereco = customer.get("address", {}).get("street"), + cep = customer.get("address", {}).get("cep"), + cidade = customer.get("address", {}).get("city"), + estado = customer.get("address", {}).get("state"), + servico = context.get("complaint", {}).get("service"), + primeiro_servico = context.get("complaint", {}).get("firstService"), + modalidade = context.get("complaint", {}).get("modality"), + motivo = context.get("complaint", {}).get("motive"), + descricao_reclamacao = context.get("complaint", {}).get("description"), + motivo_extra = motivo_extra + ) + + if use_prospect: + sr_request = SiebelProspectSRRequest( + name=customer.get("name") or "", + document_number=cpf, + email=(customer.get("email") or {}).get("email") if isinstance(customer.get("email"), dict) else customer.get("email"), + phone1=(customer.get("phones") or [None])[0], + reason1=context.get("reason1"), + reason2=context.get("reason2"), + reason3=context.get("reason3"), + notes=notas, + channel=context.get("complaint", {}).get("inputChannel", "Anatel").capitalize(), + ) + else: + sr_request = SiebelSRRequest( + social_sec_no=cpf, + asset_id=msisdn, + channel=context.get("complaint", {}).get("inputChannel", "Anatel").capitalize(), + reason1=context.get("reason1"), + reason2=context.get("reason2"), + reason3=context.get("reason3"), + notes=notas + ) + + except ValidationError as ve: + msg = f"Invalid payload for Siebel SR: {str(ve)}" + logger.error(msg, exc_info=True) + state["final_response"] = msg + event(failure_tag, { + "status": f"{failure_label} - payload inválido", + "type": "FAILURE", + "session_id": session_id, + "tag": failure_tag, + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, failure_tag, { + "apiUrl": default_url, + "apiStatusCode": "N/A", + "apiResponsePayload": "N/A", + "latencyMs": "N/A", + }), + }, metadata={"noc": True} if failure_tag == "AGA.008" else None) + + if is_tratamento: + event( + "NOC.009", + { + "status": f"Transfer to human: {ve}", + "type": "FAILURE", + **build_noc_metadata(state, "NOC.009"), + }, + metadata={"noc": True} + ) + + return set_error(state, "ValidationError", msg, step=GraphStep.SIEBEL_SR_OPENING) + + try: + client = SiebelClient( + client_id=settings.SIEBEL_API_CLIENT_ID, + username=settings.SIEBEL_API_USERNAME, + password=settings.SIEBEL_API_PASSWORD + ) + + # Call to_payload() to get the nested structure expected by Siebel + payload = sr_request.to_payload() + api_label = "Siebel Prospect" if use_prospect else "Siebel" + logger.info(f"Generated {api_label} Notes:\n{notas}") + logger.debug(f"Sending payload to {api_label}: {json.dumps(payload, indent=4, ensure_ascii=False)}") + + response, http_meta = await client.open_service_request_with_retry( + payload=payload, max_retries=2, prospect=use_prospect + ) + + # Update metadata with the SR response + sr_number = response.get("interactionProtocol") + + context = dict(context) + response["notes"] = notas + context["siebel_sr_data"] = response + + state = update_state_metadata(state, request_context=context) + await set_current_step(state, GraphStep.SIEBEL_SR_OPENED) + + msg = f"Siebel SR opened successfully: {sr_number}" + logger.info(msg+'\n') + state["final_response"] = msg + + if success_tag: + event(success_tag, { + "status": f"{success_label} - SR: {sr_number}", + "type": "SUCCESS", + "session_id": session_id, + "tag": success_tag, + "call_id": session_id, + "origin": "SIEBEL", + **build_ic_payload(state, success_tag, { + "agentSpecificData.protocolCreatorLogin": { + "name": context.get("origin", {}).get("submittedBy", {}).get("name"), + "email": context.get("origin", {}).get("submittedBy", {}).get("email"), + }, + "apiUrl": http_meta["url"], + "apiStatusCode": http_meta["status_code"], + "apiResponsePayload": http_meta["response_text"], + "latencyMs": http_meta["latency_ms"], + }), + }, metadata={"noc": True} if success_tag == "AGA.006" else None) + + # NOC.007: Delivered to ATH (entregue ao Agente de Tratamento Humano) + # Emitted when SR opened successfully for 'tratamento' and routed to SMART_HUMAN + if is_tratamento: + treatment_decision = context.get("treatment_decision", {}) + decision = treatment_decision.get("decision", {}) + agent_type = decision.get("agent_type") + + if agent_type == "SMART_HUMAN": + event( + "NOC.007", + { + "status": "Delivered to ATH", + "type": "INFO", + **build_noc_api_metadata( + state, "NOC.007", + retry_count=http_meta.get("retry_count", 0), + latency_ms=int(http_meta.get("latency_ms", 0)), + api_url=http_meta.get("url", ""), + status_code=int(http_meta.get("status_code", 0)), + ), + }, + metadata={"noc": True} + ) + + except SiebelClientError as e: + error_type = type(e).__name__ + error_detail = str(e) or repr(e) + msg = f"Failed to open Siebel SR [{error_type}]: {error_detail}\n" + state["final_response"] = msg + logger.error(msg, exc_info=True) + + # Extract HTTP metadata from exception + api_fields = { + "apiUrl": getattr(e, "url", None) or settings.SIEBEL_API_HOST, + "apiStatusCode": getattr(e, "status_code", None) if hasattr(e, "status_code") and getattr(e, "status_code", None) is not None else "N/A", + "apiResponsePayload": getattr(e, "response_text", None) if hasattr(e, "response_text") and getattr(e, "response_text", None) is not None else "N/A", + "latencyMs": getattr(e, "latency_ms", None) if getattr(e, "latency_ms", None) is not None else "N/A", + } + + event(failure_tag, { + "status": f"{failure_label} - {error_type}: {error_detail}", + "type": "FAILURE", + "session_id": session_id, + "tag": failure_tag, + "call_id": session_id, + "origin": "SIEBEL", + **build_ic_payload(state, failure_tag, api_fields), + }, metadata={"noc": True} if failure_tag == "AGA.008" else None) + + # NOC.009: Transfer due to incapacity (transferência por incapacidade) + # Emitted when SR opening fails — agent unable to process the complaint + if is_tratamento: + latency_ms = api_fields.get("latencyMs") + if isinstance(latency_ms, str): + latency_ms = 0 if latency_ms == "N/A" else int(latency_ms) + status_code = api_fields.get("apiStatusCode") + if isinstance(status_code, str): + status_code = 0 if status_code == "N/A" else int(status_code) + event( + "NOC.009", + { + "status": f"Transfer to human: {e}", + "type": "FAILURE", + **build_noc_api_metadata( + state, "NOC.009", + retry_count=getattr(e, "attempts", 0), + latency_ms=latency_ms or 0, + api_url=api_fields.get("apiUrl", ""), + status_code=status_code or 0, + ), + }, + metadata={"noc": True} + ) + + return set_error(state, error_type, error_detail, step=GraphStep.SIEBEL_SR_OPENING) + + return state + +def should_continue(state: AgentState) -> str: + """ + Router condition to decide whether to continue the graph after validation. + """ + if state.get("current_step") == GraphStep.SIEBEL_SR_OPENED: + return "continue" + return "failed" diff --git a/src/agent/nodes/siebel_triplet_classification_node.py b/src/agent/nodes/siebel_triplet_classification_node.py new file mode 100644 index 0000000..830f8e4 --- /dev/null +++ b/src/agent/nodes/siebel_triplet_classification_node.py @@ -0,0 +1,133 @@ +import time +import json +from src.agent.state.agent_state import AgentState, update_state_metadata +from src.core.logging import get_logger +from src.providers.llm_provider import classification_llm, chat_llm_with_usage +from src.utils.observer import trace_node, score_current_trace +from src.core.prompt_manager import get_prompt +from src.utils.ics_collector import build_ic_payload + +# DEPRECATED IN NEW ARCHITECTURE: +# This node and its associated prompt are kept available for future +# complex triplet classifications, but currently we assign a fixed triplet +# for "cancelamento" locally inside `canceling_analysis_node`. + +logger = get_logger(__name__) + +# Exact values allowed by the Siebel API to prevent crashes +VALID_TRIPLETS = [ + {"reason1": "Processo Interno", "reason2": "Atendimento", "reason3": "ID Responsabilidade Anatel"}, + {"reason1": "Processo Interno", "reason2": "Atendimento", "reason3": "Cancelamento ID Anatel"}, + {"reason1": "Processo Interno", "reason2": "Atendimento", "reason3": "ID Outra Operadora"}, + {"reason1": "Processo Interno", "reason2": "Atendimento", "reason3": "Reclassificação ID"} +] + +def _validate_triplet(triplet: dict, decision: str) -> dict: + """Ensures the triplet exists in the valid list. Returns fallback if it doesn't match perfectly.""" + for valid in VALID_TRIPLETS: + if (valid["reason1"] == triplet.get("reason1") and + valid["reason2"] == triplet.get("reason2") and + valid["reason3"] == triplet.get("reason3")): + return valid + + # Fallback to defaults to guarantee Siebel safety + if decision == "cancelar": + return {"reason1": "Processo Interno", "reason2": "Atendimento", "reason3": "Cancelamento ID Anatel"} + else: + return {"reason1": "Processo Interno", "reason2": "Atendimento", "reason3": "Reclassificação ID"} + +async def _classify_triplet_call(context: dict, session_id: str = "") -> tuple[dict, dict]: + """ + Classifies the ticket triplets using the LLM. + Returns (validated_triplet, llm_meta) — the validated triplet dict + with 'reason1', 'reason2', 'reason3' (empty dict on failure) plus + llm_meta carrying the raw LLM content and prompt for IC payload. + Enforces rigid API output constraint. + """ + llm = classification_llm + llm_meta: dict = {"content": None, "prompt": None, "model": str(llm.eligibleModel_name), "latency_ms": 0} + + decision = context.get("siebel_action") + payload = { + "original_context": { + "service": context.get("anatel", {}).get("service"), + "first_service": context.get("anatel", {}).get("first_service"), + "modality": context.get("anatel", {}).get("modality"), + "motive": context.get("anatel", {}).get("motive"), + "description": context.get("anatel", {}).get("description") + }, + "decision": decision, + "reasoning": context.get("reasoning"), + } + + if decision == "cancelar": + payload["cancel_reason"] = context.get("cancel_reason") + + # Get prompt from Langfuse with local fallback + prompt = get_prompt("llm_triplet_classification_pt", classificacao_tripletas_pt) + message = f"{prompt}\n\n[Payload da Reclamação]:\n{json.dumps(payload, ensure_ascii=False)}" + llm_meta["prompt"] = message + + # LLM generation telemetry is recorded by agent_framework.llm.providers. + + try: + _t0 = time.perf_counter() + llm_resp = chat_llm_with_usage(llm, message, expect_json=True) + content = llm_resp.content + llm_meta["content"] = content + llm_meta["latency_ms"] = int(llm_resp.latency_ms) + llm_meta["model"] = llm_resp.model + logger.info(f"Triplet llm full response: {content}") + + parsed_response = llm_resp.parsed_json or {} + validated = _validate_triplet(parsed_response, decision) + + score_current_trace(name="triplet_valid", value=1.0) + + return validated, llm_meta + + except Exception as e: + logger.error(f"Error calling LLM for triplet classification: {e}", exc_info=True) + llm_meta["latency_ms"] = int((time.perf_counter() - _t0) * 1000) + score_current_trace(name="triplet_valid", value=0.0, comment=str(e)) + return {}, llm_meta + +@trace_node +async def classify_siebel_triplet(state: AgentState) -> AgentState: + """ + Second node in the classification flow. Reads the 'cancelar' + decision, asks the LLM for the proper reason 1, 2, and 3, and appends them to the context. + """ + session_id = state.get("session_id", "") + logger.info("Running triplet classification node") + + state["current_step"] = "processing_triplet_classification" + + context = state.get("metadata", {}).get("request_context", {}) + decision = context.get("siebel_action") + + if decision != "cancelar": + logger.warning(f"Triplet classification called for decision '{decision}'. Expected 'cancelar'.") + # If it's not cancel, it should have been handled by the previous node but we set to END just in case + state["current_step"] = "siebel_triplet_classified" + return state + + triplet_result, llm_meta = await _classify_triplet_call(context, session_id=session_id) + + if not triplet_result or "reason1" not in triplet_result: + logger.error("Failed to parse valid triplets from LLM") + state["current_step"] = "siebel_triplet_classification_failed" + return state + + logger.info(f"LLM assigned triplets: {triplet_result}") + + # Enriches request_context with reason1, reason2, reason3 + updated_context = { + **context, + **triplet_result + } + + state = update_state_metadata(state, request_context=updated_context) + state["current_step"] = "siebel_triplet_classified" + + return state diff --git a/src/agent/nodes/speech_enrichment_node.py b/src/agent/nodes/speech_enrichment_node.py new file mode 100644 index 0000000..3012b35 --- /dev/null +++ b/src/agent/nodes/speech_enrichment_node.py @@ -0,0 +1,168 @@ +import time +import logging +import json +from src.agent.state.agent_state import AgentState, update_state_metadata +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.components.clients.speech_analytics_client import SpeechAnalyticsClient +from src.components.clients.exceptions.speech_exceptions import SpeechClientError +from src.utils.observer import trace_node, score_current_trace +from src.utils.ics_collector import build_ic_payload, build_noc_api_metadata, build_noc_llm_metadata +from src.compat.framework_observer import event +from src.providers.llm_provider import classification_llm, chat_llm_with_usage, LLM_ENDPOINT +from src.core.config import settings +from src.core.prompt_manager import get_prompt +from src.agent.local_prompts.speech_history_analysis import speech_history_similarity_pt + +logger = logging.getLogger(__name__) + +_NO_SIMILAR_MESSAGE = "Não foram encontradas reclamações anteriores similares à reclamação atual" +_HISTORY_UNAVAILABLE_MESSAGE = "Indisponível SPEECH para Histórico de Reclamação - realizar consulta manualmente" + +_HISTORY_DETAIL_KEYS = ( + "reclamacao_resumo", + "causa_raiz", + "descortesia_cliente", + "motivo_reclamacao", + "submotivo_reclamacao", + "sentimento_cliente", + "solucao_proposta_cliente", +) + +_NULL_SPEECH_DATA = { + "reclamacao_resumo": None, + "causa_raiz": None, + "descortesia_cliente": None, + "motivo_reclamacao": None, + "submotivo_reclamacao": None, + "sentimento_cliente": None, + "solucao_proposta_cliente": None, + "analise_agente": None, +} + + +def _map_prediction_response(response: dict) -> dict: + """ + Map the raw Prediction API response to the internal speech_analytics schema. + """ + variables = response.get("variables", {}) + return { + "reclamacao_resumo": response.get("resume") or variables.get("resume"), + "causa_raiz": variables.get("causa_raiz"), + "descortesia_cliente": variables.get("descortesia_cliente"), + "motivo_reclamacao": variables.get("motivo"), + "submotivo_reclamacao": variables.get("submotivo"), + "sentimento_cliente": variables.get("sentimento_cliente"), + "solucao_proposta_cliente": variables.get("solucao_proposta_cliente"), + } + + +def _build_related_history( + raw_history: list, + current_complaint_id: str | None, + llm_scores: list, + threshold: int, +) -> list: + """ + Merge LLM similarity scores with raw history items and filter by threshold. + + Each returned item has: protocolo, data_reclamacao, similaridade_pct, + reasoning, plus the 7 detail keys (same as reclamacao_atual). + """ + if not raw_history or not llm_scores: + return [] + + history_by_protocol = {str(item.get("protocolo")): item for item in raw_history} + related: list[dict] = [] + + for score in llm_scores: + protocolo = str(score.get("protocolo")) if score.get("protocolo") is not None else None + if not protocolo or protocolo == str(current_complaint_id or ""): + continue + try: + similaridade_pct = int(score.get("similaridade_pct", 0)) + except (TypeError, ValueError): + continue + if similaridade_pct < threshold: + continue + + raw_item = history_by_protocol.get(protocolo) + if not raw_item: + continue + + merged = { + "protocolo": raw_item.get("protocolo"), + "data_reclamacao": raw_item.get("data_reclamacao"), + "similaridade_pct": similaridade_pct, + "reasoning": (score.get("reasoning") or "").strip(), + } + for key in _HISTORY_DETAIL_KEYS: + merged[key] = raw_item.get(key) + related.append(merged) + + related.sort(key=lambda x: x["similaridade_pct"], reverse=True) + return related + + +def _build_analise_agente(related_items: list) -> str: + """ + Build the analise_agente string from related items: one line per item with + 'Protocolo X (Y%): <reasoning>'. Empty list -> negative spec message. + """ + if not related_items: + return _NO_SIMILAR_MESSAGE + + lines = [] + for item in related_items: + reasoning = (item.get("reasoning") or "").strip() + if not reasoning: + reasoning = "—" + lines.append(f"Protocolo {item.get('protocolo')} ({item.get('similaridade_pct')}%): {reasoning}") + return "\n".join(lines) + + +async def _score_history_with_llm( + current_id: str | None, + current_complaint_description: str, + history_list: list, + session_id: str = "", + state: dict | None = None, +) -> list: + """ + Calls the LLM to score similarity between the current complaint description + and each historical item. Returns the LLM-parsed JSON list. Items below + SPEECH_SIMILARITY_THRESHOLD will be dropped later by _build_related_history. + """ + if not current_complaint_description or not history_list: + return [] + + clean_history = [ + item for item in history_list + if str(item.get("protocolo")) != str(current_id or "") + ] + if not clean_history: + logger.info("No historical items left after deduplication.") + return [] + + llm = classification_llm + prompt_template = get_prompt("speech_history_similarity_pt", speech_history_similarity_pt) + + history_payload = [ + { + "protocolo": item.get("protocolo"), + "motivo_reclamacao": item.get("motivo_reclamacao"), + "submotivo_reclamacao": item.get("submotivo_reclamacao"), + "causa_raiz": item.get("causa_raiz"), + "reclamacao_resumo": item.get("reclamacao_resumo"), + } + for item in clean_history + ] + + message = prompt_template.format( + current_complaint_description=current_complaint_description, + history_json=json.dumps(history_payload, ensure_ascii=False), + ) + + logger.info(f"Calling LLM for history similarity filtering. Items: {len(clean_history)}") + + # LLM generation telemetry is recorded by agent_framework.llm.providers. diff --git a/src/agent/nodes/tim_complaint_analysis_node.py b/src/agent/nodes/tim_complaint_analysis_node.py new file mode 100644 index 0000000..9ee5350 --- /dev/null +++ b/src/agent/nodes/tim_complaint_analysis_node.py @@ -0,0 +1,224 @@ +import time +import json +import logging +from src.agent.state.agent_state import AgentState, update_state_metadata, set_error +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.core.config import settings +from src.providers.llm_provider import classification_llm, chat_llm_with_usage, LLM_ENDPOINT +from src.utils.observer import trace_node, score_current_trace +from src.compat.framework_observer import event +from src.agent.local_prompts.tim_complaint_analysis import tim_complaint_analysis_pt +from src.core.prompt_manager import get_prompt +from src.utils.ics_collector import build_ic_payload, build_llm_token_fields, build_noc_llm_metadata, build_noc_metadata + +logger = logging.getLogger(__name__) + +class IdentifyTimComplaintError(Exception): + pass + +async def _identify_tim_complaint(context: dict, session_id: str = "") -> tuple[dict, dict]: + """ + Calls LLM to analyzes ticket description to see if it is related to TIM or not. + Returns a tuple (parsed_response, llm_meta), where llm_meta carries + the raw LLM content and prompt so the caller can fill LLM-origin + IC payload fields (llmResponse, promptLength, ...). + """ + llm = classification_llm + llm_meta: dict = {"content": None, "prompt": None, "model": str(llm.eligibleModel_name), "latency_ms": 0} + + imdb_data = context.get("imdb_access_data") + + if imdb_data: + enrichment_status_code = imdb_data.get("status_code") + enrichment_status_type = imdb_data.get("statusType") + else: + logger.warning("IMDB access data not found. Probably MSISDN was not provided and no enrichment occurred.") + enrichment_status_code = "Não disponível (MSISDN não fornecido)" + enrichment_status_type = "Não disponível" + + payload = { + "description": context.get("complaint", {}).get("description"), + "motive": context.get("complaint", {}).get("motive"), + "modality": context.get("complaint", {}).get("modality"), + "service": context.get("complaint", {}).get("service"), + "imdb_enrichment_status_code": enrichment_status_code, + "imdb_enrichment_status_type": enrichment_status_type + } + logger.info(f"Identify Complaint Operator Payload: {json.dumps(payload, ensure_ascii=False, indent=4)}") + + # Get prompt from Langfuse with local fallback + prompt = get_prompt("ticket_tim_complaint_analysis_pt", tim_complaint_analysis_pt) + message = f"{prompt}\n\n[Complaint Payload]:\n{json.dumps(payload, ensure_ascii=False)}" + llm_meta["prompt"] = message + + # LLM generation telemetry is recorded by agent_framework.llm.providers. + + try: + _t0 = time.perf_counter() + llm_resp = chat_llm_with_usage(llm, message, expect_json=True) + content = llm_resp.content + llm_meta["content"] = content + llm_meta["latency_ms"] = int(llm_resp.latency_ms) + llm_meta["model"] = llm_resp.model + llm_meta["prompt_tokens"] = llm_resp.prompt_tokens + llm_meta["completion_tokens"] = llm_resp.completion_tokens + logger.debug(f"Identify Complaint Operator LLM response: {content}") + + parsed_response = llm_resp.parsed_json or {} + decision = parsed_response.get("is_tim_complaint", "").lower() + + _VALID_DECISIONS = ["sim", "não", "inconclusivo"] + if decision not in _VALID_DECISIONS: + msg = f"TIM complaint analysis LLM returned unknown decision: {decision}. Expected one of {_VALID_DECISIONS}." + logger.error(msg) + err = IdentifyTimComplaintError(msg) + err.llm_meta = llm_meta + raise err + + score_current_trace(name="is_tim_complaint_valid", value=1.0 if decision == "sim" else 0.0) + + return parsed_response, llm_meta + + except IdentifyTimComplaintError: + raise + except Exception as e: + error_type = str(type(e).__name__) + msg = f"{error_type} calling LLM to identify complaint operator: {e}" + logger.error(msg, exc_info=True) + llm_meta["latency_ms"] = int((time.perf_counter() - _t0) * 1000) + err = IdentifyTimComplaintError(msg) + err.llm_meta = llm_meta + raise err from e + +@trace_node +async def perform_tim_complaint_analysis(state: AgentState) -> AgentState: + """ + Identify whether the ticket description relates to TIM or not. + """ + try: + session_id = state.get("session_id", "") + causa_raiz = state.get("metadata", {}).get("request_context", {}).get("speech_analytics", {}).get("causa_raiz", "N/A") + logger.info("Running tim complaint identification node") + await set_current_step(state, GraphStep.TIM_COMPLAINT_ANALYSIS) + + context = state.get("metadata", {}).get("request_context", {}) + + result, llm_meta = await _identify_tim_complaint(context, session_id=session_id) + + decision = result.get("is_tim_complaint", "").lower() + reasoning = result.get("reasoning") + operator = result.get("operator") + operator_normalized = operator.strip().upper() if isinstance(operator, str) and operator.strip() else None + + # Normalização defensiva: o prompt instrui o LLM a sempre devolver + # "ausente na reclamação" quando o dado não estiver presente. Caso a + # propriedade venha None (LLM antigo / falha de extração), substituímos + # pela mesma string para evitar `None` no texto da resposta externa. + complaint_summary = result.get("complaint_summary") or "ausente na reclamação" + complaint_quote = result.get("complaint_quote") or "ausente na reclamação" + + logger.info( + f"TIM complaint analysis completed successfully with is_tim_complaint decision: {decision}. " + f"Context operator: {operator_normalized!r}. Model Reasoning: {reasoning}\n" + ) + + updated_context = { + **context, + "is_tim_complaint": decision, + "model_reasoning": reasoning, + "complaint_context_operator": operator_normalized, + "complaint_summary": complaint_summary, + "complaint_quote": complaint_quote, + } + + if decision == "não": + event("AGA.004", { + "status": "Agente decidiu reencaminhar o ticket (reclamação não é sobre a TIM)", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.004", + "call_id": session_id, + "origin": "LLM", + "step": "tim_complaint_analysis", + **build_ic_payload(state, "AGA.004", { + "agentSpecificData.statusCode": "reencaminhar", + "intention": causa_raiz, + **build_llm_token_fields(llm_meta), + }), + }) + elif decision == "inconclusivo": + event("AGA.005", { + "status": "Agente decidiu continuar via LLM (reclamação TIM inconclusiva)", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.005", + "call_id": session_id, + "origin": "LLM", + "step": "tim_complaint_analysis", + **build_ic_payload(state, "AGA.005", { + "agentSpecificData.statusCode": "inconclusivo", + "intention": causa_raiz, + **build_llm_token_fields(llm_meta), + }), + }) + else: # decision == "sim" + event("AGA.005", { + "status": "Agente decidiu seguir com o ticket (reclamação é sobre a TIM)", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.005", + "call_id": session_id, + "origin": "LLM", + "step": "tim_complaint_analysis", + **build_ic_payload(state, "AGA.005", { + "agentSpecificData.statusCode": "encaminhar", + "intention": causa_raiz, + **build_llm_token_fields(llm_meta), + }), + }) + + state = update_state_metadata(state, request_context=updated_context) + return state + except IdentifyTimComplaintError as e: + _llm_meta = getattr(e, "llm_meta", {}) + event( + "NOC.004", + { + "status": f"LLM error during TIM complaint analysis: {e}", + "type": "FAILURE", + **build_noc_llm_metadata( + state, "NOC.004", + latency_ms=_llm_meta.get("latency_ms") or 0, + llm_endpoint=LLM_ENDPOINT, + model_name=_llm_meta.get("model") or str(classification_llm.eligibleModel_name), + ), + }, + metadata={"noc": True} + ) + event( + "NOC.009", + { + "status": f"Transfer to human: {e}", + "type": "FAILURE", + **build_noc_metadata(state, "NOC.009"), + }, + metadata={"noc": True} + ) + state["final_response"] = str(e) + return set_error(state, "IdentifyComplaintOperatorError", str(e), step=GraphStep.TIM_COMPLAINT_ANALYSIS_FAILED) + except Exception as e: + error_type = str(type(e).__name__) + msg = f"{error_type} calling LLM to identify tim complaint: {e}" + event( + "NOC.009", + { + "status": f"Transfer to human: {msg}", + "type": "FAILURE", + **build_noc_metadata(state, "NOC.009"), + }, + metadata={"noc": True} + ) + state["final_response"] = msg + logger.error(msg, exc_info=True) + return set_error(state, error_type, msg, step=GraphStep.TIM_COMPLAINT_ANALYSIS_FAILED) diff --git a/src/agent/nodes/tim_complaint_node.py b/src/agent/nodes/tim_complaint_node.py new file mode 100644 index 0000000..f0137ea --- /dev/null +++ b/src/agent/nodes/tim_complaint_node.py @@ -0,0 +1,37 @@ +import logging +from src.agent.state.agent_state import AgentState +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.utils.observer import trace_node +from src.utils.ics_collector import build_ic_payload +from src.compat.framework_observer import event +from src.utils.forwarding_helpers import do_not_forward, DO_NOT_FORWARD_REASON_DESCRIPTION_TIM + +logger = logging.getLogger(__name__) + +@trace_node +async def handle_tim_complaint(state: AgentState) -> AgentState: + """ + Node that handles complaints confirmed as being for TIM. + Currently a placeholder that leads to reclassification. + """ + session_id = state.get("session_id", "") + context = state.get("metadata", {}).get("request_context", {}) + + logger.info("Handling TIM confirmed complaint. Proceeding to reclassification.") + await set_current_step(state, GraphStep.RECLASSIFICATION_ANALYSIS) + + do_not_forward(state, context, reason=DO_NOT_FORWARD_REASON_DESCRIPTION_TIM, target_operator="TIM") + + event("AGA.005", { + "status": "Agente decidiu continuar com o tratamento do ticket atual", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.005", + "call_id": session_id, + "origin": "AGENT", + "step": "tim_complaint_analysis", + **build_ic_payload(state, "AGA.005"), + }) + + return state diff --git a/src/agent/nodes/treatment_decision_node.py b/src/agent/nodes/treatment_decision_node.py new file mode 100644 index 0000000..bdd1792 --- /dev/null +++ b/src/agent/nodes/treatment_decision_node.py @@ -0,0 +1,147 @@ +import time +import json +from src.compat.framework_observer import event +from src.utils.observer import trace_node +from src.utils.ics_collector import build_ic_payload, build_llm_token_fields, build_noc_llm_metadata, build_noc_metadata +from src.providers.llm_provider import classification_llm, chat_llm_with_usage, LLM_ENDPOINT +from src.agent.local_prompts.treatment_decision_prompt import treatment_decision_prompt +from src.core.logging import get_logger + +logger = get_logger(__name__) + + +@trace_node +async def treatment_decision(state: dict) -> dict: + """ + Node responsável por: + - Classificar o chamado + - Decidir roteamento (IA ou Smart Human) + - Registrar histórico + """ + + session_id = state.get("session_id", "") + llm = classification_llm + state["treatment_decision"] = {} + treatment_decision_state = state["treatment_decision"] + + # ========================= + # 0. Orquestração manual + # ========================= + + request_context = state.get("metadata", {}).get("request_context", {}) + complaint = request_context.get("complaint", {}) + reabertura = complaint.get("actionType", "").lower() == "reabertura" + smart_human_handling = request_context.get("smart_human_handling", False) + + if smart_human_handling or reabertura: + logger.debug("Identified smart human handling exclusive scenarios (absent msisdn or reopening ticket)") + + event("AGA.015", { + "status": "Houve acionamento do Agente Especializado (LLM): não — smart_human_handling ou reabertura", + "type": "INFO", + "session_id": session_id, + "tag": "AGA.015", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.015"), + }) + + treatment_decision_state["classification"] = { + "motive": complaint.get("motive"), + "description": complaint.get("description"), + "category": "SMART_HUMAN", + } + + treatment_decision_state["decision"] = { + "agent_type": "SMART_HUMAN", + "target_agent": None, + } + + smart_human_reason = request_context.get("smart_human_reason") + treatment_decision_state["treatment_decision_reasoning"] = ( + smart_human_reason + or ("Classificado como SMART_HUMAN devido à ausência do msisdn." if smart_human_handling else + "Classificado como SMART_HUMAN devido à reabertura do chamado.") + ) + + history_entry = { + "step": "treatment_decision", + "classification": treatment_decision_state["classification"], + "decision": treatment_decision_state["decision"], + "reasoning": treatment_decision_state["treatment_decision_reasoning"], + } + + if "history" not in state: + state["history"] = [] + + state["history"].append(history_entry) + + state["metadata"]["request_context"]["treatment_decision"] = { + "category": "SMART_HUMAN", + "decision": treatment_decision_state["decision"], + "treatment_decision_reasoning": treatment_decision_state["treatment_decision_reasoning"], + } + + return state + + # ========================= + # 1. Inputs do state + # ========================= + + metadata = state.get("metadata", {}) + request_context = metadata.get("request_context", {}) + + complaint = request_context.get("complaint", {}) + customer = request_context.get("customer", {}) + imdb_data = request_context.get("imdb_access_data", {}) + + modality = complaint.get("modality", "") or "" + motive = complaint.get("motive", "") or "" + description = complaint.get("description", "") or "" + + # insights podem vir de múltiplos lugares dependendo do fluxo + speech_insights = ( + state.get("model_reasoning") + or metadata.get("model_reasoning") + or state.get("speech_insights") + or "" + ) + + # contexto enriquecido para LLM (melhor do que string vazia) + contexto = { + "ticket_id": request_context.get("ticketId"), + "case_type": request_context.get("caseType"), + "service": complaint.get("service"), + "first_service": complaint.get("firstService"), + "customer_name": customer.get("name"), + "customer_msisdn": customer.get("msisdn"), + "customer_cpfCnpj": customer.get("cpfCnpj"), + "plan": imdb_data.get("plan"), + "status": imdb_data.get("statusType"), + "address": customer.get("address"), + } + + prompt_input = { + "motive": motive, + "description": description, + "speech_insights": speech_insights, + "contexto": contexto, + "modality": modality, + } + + # ========================= + # 2. Monta prompt + # ========================= + + message = ( + f"{treatment_decision_prompt}\n\n" + f"[Complaint Payload]:\n{json.dumps(prompt_input, ensure_ascii=False)}" + ) + + logger.debug(f"Invoking LLM with {len(message)} characters") + + # ========================= + # 3. Trace + # ========================= + + # LLM generation telemetry is recorded by agent_framework.llm.providers. diff --git a/src/agent/nodes/undefined_complaint_operator_node.py b/src/agent/nodes/undefined_complaint_operator_node.py new file mode 100644 index 0000000..7a3ad59 --- /dev/null +++ b/src/agent/nodes/undefined_complaint_operator_node.py @@ -0,0 +1,416 @@ +import logging +from datetime import datetime + +from src.agent.state.agent_state import AgentState +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.components.clients.abrt_client import AbrtClient +from src.components.clients.portability_client import PortabilityClient + +from src.components.clients.exceptions.abrt_exceptions import AbrtClientError +from src.components.clients.exceptions.portability_exceptions import ( + PortabilityClientError, +) +from src.core.config import settings + + +from src.utils.forwarding_helpers import ( + do_not_forward, + forward_complaint, + is_tim, + FORWARD_REASON_ABRT_OPERATOR, + FORWARD_REASON_PORTABILITY_OPERATOR, + DO_NOT_FORWARD_REASON_NO_MSISDN, + DO_NOT_FORWARD_REASON_IMDB_MISSING, + DO_NOT_FORWARD_REASON_IMDB_TIM, + DO_NOT_FORWARD_REASON_ABRT_ERROR, + DO_NOT_FORWARD_REASON_ABRT_NOT_FOUND, + DO_NOT_FORWARD_REASON_ABRT_INVALID, + DO_NOT_FORWARD_REASON_ABRT_TIM, + DO_NOT_FORWARD_REASON_PORTABILITY_TIM, + DO_NOT_FORWARD_REASON_PORTABILITY_UNDETERMINED, +) +from src.utils.observer import trace_node, trace_tool +from src.utils.ics_collector import build_ic_payload, build_noc_api_metadata, build_noc_metadata +from src.compat.framework_observer import event + +logger = logging.getLogger(__name__) + + +IMDB_TIM_ACTIVE_STATUSES = {"Ativo", "Suspenso", "Bloqueado"} +IMDB_TIM_INACTIVE_STATUSES = {"Cancelado", "Inativo"} + + +@trace_node +async def perform_undefined_complaint(state: AgentState) -> AgentState: + """Forwarding decision when the complaint does not name an operator: IMDB → ABRT → portability.""" + try: + session_id = state.get("session_id", "") + await set_current_step(state, GraphStep.FORWARDING_ANALYSIS) + + context = state.get("metadata", {}).get("request_context", {}) + customer = context.get("customer", {}) + + cpf = customer.get("cpfCnpj") + msisdn = customer.get("msisdn") + + # Step 1: missing MSISDN — keep with TIM. + if not msisdn: + logger.info( + "MSISDN not provided. Proceeding with complaint (TIM).", + extra={"customer": customer} + ) + return do_not_forward(state, context, reason=DO_NOT_FORWARD_REASON_NO_MSISDN) + + # Step 2: inspect prior IMDB call. + imdb_result = context.get("imdb_access_data") + + if imdb_result is None: + logger.warning( + "IMDB result not found. Proceeding with complaint (TIM) as fallback." + ) + return do_not_forward(state, context, reason=DO_NOT_FORWARD_REASON_IMDB_MISSING) + + if imdb_result.get("status_code") == 200: + status_type = imdb_result.get("statusType") + + logger.info(f"IMDB status 200 | statusType: {status_type!r}") + + if status_type in IMDB_TIM_ACTIVE_STATUSES: + logger.info("IMDB indicates active TIM. Proceeding with complaint.") + + return do_not_forward(state, context, reason=DO_NOT_FORWARD_REASON_IMDB_TIM, target_operator="TIM") + + if status_type in IMDB_TIM_INACTIVE_STATUSES: + logger.info("IMDB inactive. Proceeding to ABRT.") + else: + logger.warning("Unexpected IMDB statusType. Proceeding to ABRT.") + + event("NOC.002", { + "status": "Invalid API response", + "type": "WARNING", + **build_noc_api_metadata( + state, "NOC.002", + retry_count=imdb_result.get("retry_count", 0), + latency_ms=imdb_result.get("latency_ms", 0), + api_url=imdb_result.get("url", settings.PMID_API_HOST), + status_code=imdb_result.get("status_code"), + ), + }, metadata={"noc": True}) + + if imdb_result.get("status_code") == 204: + logger.info("IMDB 204. Proceeding to ABRT.") + + # Step 3: query ABRT. + abrt_result = await _fetch_abrt(cpf, msisdn, state=state, session_id=session_id) + if abrt_result is None: + logger.warning("ABRT error. Proceeding with complaint (TIM).", extra={"msisdn": msisdn}) + + return do_not_forward(state, context, reason=DO_NOT_FORWARD_REASON_ABRT_ERROR) + + status = abrt_result.get("status") + company = abrt_result.get("company") + + # ABRT did not find the number. + if status == "2": + logger.info("ABRT did not find customer. Proceeding with complaint.", extra={"msisdn": msisdn}) + + return do_not_forward(state, context, reason=DO_NOT_FORWARD_REASON_ABRT_NOT_FOUND) + + # Invalid ABRT response. + if status not in ["0", "1"] or not company: + logger.warning("ABRT invalid response. Proceeding with complaint.", extra={"status": status, "company": company}) + + event("NOC.002", { + "status": "Invalid API response", + "type": "WARNING", + **build_noc_api_metadata( + state, "NOC.002", + retry_count=abrt_result.get("retry_count", 0), + latency_ms=abrt_result.get("_latency_ms", 0), + api_url=abrt_result.get("_api_url", settings.ABRT_API_BASE_URL), + status_code=200, + ), + }, metadata={"noc": True}) + + return do_not_forward(state, context, reason=DO_NOT_FORWARD_REASON_ABRT_INVALID) + + company_normalized = company.strip().upper() + + if is_tim(company_normalized): + logger.info("ABRT indicates TIM. Proceeding with complaint.", extra={"msisdn": msisdn}) + + return do_not_forward(state, context, reason=DO_NOT_FORWARD_REASON_ABRT_TIM, target_operator="TIM") + + # Step 4: query portability to resolve owner at `openedAt`. + logger.info("ABRT indicates another operator. Checking port-out history.",extra={"company": company_normalized, "msisdn": msisdn}) + + portability_response = await fetch_portability(cpf, msisdn, state=state, session_id=session_id) + if portability_response: + status_code = portability_response.get("status_code") + data = portability_response.get("data") + + # 200/204 + empty body / no port-outs → forward to the ABRT operator. + if status_code in (200, 204) and (not data or not data.get("portabilities")): + logger.warning( + "Portability returned no history. Forwarding complaint to ABRT operator.", + extra={"status_code": status_code, "msisdn": msisdn} + ) + + return forward_complaint( + state, + context, + reason=FORWARD_REASON_PORTABILITY_OPERATOR, + operator=company_normalized + ) + + if data: + is_tim_owner, operator, portability_meta = _evaluate_portability(context, data) + + if is_tim_owner: + logger.info( + f"Portability identifies account as TIM ({operator}). Proceeding with complaint.", + extra={"portability_meta": portability_meta}, + ) + return do_not_forward(state, context, reason=DO_NOT_FORWARD_REASON_PORTABILITY_TIM, target_operator="TIM") + + if operator: + logger.info( + f"Portability identifies account as {operator!r}. Forwarding complaint.", + extra={"portability_meta": portability_meta}, + ) + return forward_complaint(state, context, reason=FORWARD_REASON_PORTABILITY_OPERATOR, operator=operator) + + # Fallback: inconclusive/unavailable portability → use ABRT operator. + logger.warning( + "Portability data inconclusive or unavailable. Forwarding to ABRT operator.", + extra={"msisdn": msisdn, "abrt_operator": company_normalized} + ) + + return forward_complaint( + state, + context, + reason=FORWARD_REASON_ABRT_OPERATOR, + operator=company_normalized + ) + + except Exception as e: + msg = f"{type(e).__name__}: {e}" + await set_current_step(state, GraphStep.FORWARDING_ANALYSIS_FAILED) + + event("NOC.009", { + "status": f"Forwarding analysis failed: {e}", + "type": "FAILURE", + **build_noc_metadata(state, "NOC.009"), + }, metadata={"noc": True}) + + logger.error(msg, exc_info=True) + raise + + +# --------------------------------------------------------------------------- +# Helpers de APIs +# --------------------------------------------------------------------------- + + +async def _fetch_abrt( + cpf: str, + msisdn: str, + state: AgentState | None = None, + session_id: str = "", +) -> dict | None: + try: + client = AbrtClient() + response, http_meta = await client.get_abrt_data_with_retry( + social_sec_no=cpf, + msisdn=msisdn, + max_retries=2, + ) + + api_fields = { + "apiUrl": http_meta["url"], + "apiStatusCode": http_meta["status_code"], + "apiResponsePayload": http_meta["response_text"], + "latencyMs": http_meta["latency_ms"], + } + + event("AGA.037", { + "status": "API reencaminhamento: Consulta à ABRT", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.037", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.037", api_fields), + }) + + result = response.model_dump() + result["_latency_ms"] = http_meta["latency_ms"] + result["_api_url"] = http_meta["url"] + result["retry_count"] = http_meta.get("retry_count", 0) + return result + + except AbrtClientError as exc: + api_fields = { + "apiUrl": getattr(exc, "url", None) or settings.ABRT_API_BASE_URL or "N/A", + "apiStatusCode": getattr(exc, "status_code", None) if getattr(exc, "status_code", None) is not None else "N/A", + "apiResponsePayload": getattr(exc, "response_text", None) or str(exc), + "latencyMs": getattr(exc, "latency_ms", None) if getattr(exc, "latency_ms", None) is not None else "N/A", + } + + logger.error(f"Failed to fetch ABRT data: {exc}", exc_info=True) + event("AGA.038", { + "status": f"API reencaminhamento: Erro Consulta à ABRT - {type(exc).__name__}: {exc}", + "type": "FAILURE", + "session_id": session_id, + "tag": "AGA.038", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.038", api_fields), + }) + return None + + +# --------------------------------------------------------------------------- +# Helper de portabilidade (placeholder) +# --------------------------------------------------------------------------- + +async def fetch_portability( + cpf: str, + msisdn: str, + state: AgentState | None = None, + session_id: str = "", +) -> dict | None: + try: + client = PortabilityClient() + response, http_meta = await client.get_portability_history_with_retry( + social_sec_no=cpf, + msisdn=msisdn, + max_retries=2, + ) + + status_code = response.get("status_code") + data = response.get("data") + + api_fields = { + "apiUrl": http_meta["url"], + "apiStatusCode": http_meta["status_code"], + "apiResponsePayload": http_meta["response_text"], + "latencyMs": http_meta["latency_ms"], + } + + if data is None: + logger.warning( + "Portability returned empty data.", + extra={"status_code": status_code, "msisdn": msisdn} + ) + + event("AGA.039", { + "status": "API reencaminhamento: Consulta Portabilidade", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.039", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.039", api_fields), + }) + + return { + "status_code": status_code, + "data": data.model_dump() if data else None + } + + except PortabilityClientError as exc: + api_fields = { + "apiUrl": getattr(exc, "url", None) or settings.PORTABILITY_API_URL or "N/A", + "apiStatusCode": getattr(exc, "status_code", None) if getattr(exc, "status_code", None) is not None else "N/A", + "apiResponsePayload": getattr(exc, "response_text", None) or str(exc), + "latencyMs": getattr(exc, "latency_ms", None) if getattr(exc, "latency_ms", None) is not None else "N/A", + } + + logger.error(f"Portability API error: {exc}", exc_info=True) + event("AGA.040", { + "status": f"API reencaminhamento: Erro Consulta Portabilidade - {type(exc).__name__}: {exc}", + "type": "FAILURE", + "session_id": session_id, + "tag": "AGA.040", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.040", api_fields), + }) + return None + +def _parse_opened_at(context: dict) -> datetime | None: + opened_at = context.get("complaint", {}).get("openedAt") + if not opened_at: + return None + if isinstance(opened_at, str): + opened_at = datetime.fromisoformat(opened_at) + if opened_at.tzinfo is not None: + opened_at = opened_at.replace(tzinfo=None) + return opened_at + + +def _parse_portability_events(items: list[dict]) -> list[tuple[datetime, dict]]: + parsed: list[tuple[datetime, dict]] = [] + for item in items: + cancel_str = item.get("cancellationDate") + if not cancel_str: + continue + try: + cancel = datetime.fromisoformat(cancel_str) + except ValueError: + continue + parsed.append((cancel, item)) + parsed.sort(key=lambda pair: pair[0]) + return parsed + + +def _resolve_portability_owner( + parsed: list[tuple[datetime, dict]], + opened_at: datetime, +) -> tuple[str, dict]: + """Returns (operator_name, meta) at the complaint's opening date.""" + last_before = next( + (pair for pair in reversed(parsed) if pair[0] <= opened_at), + None, + ) + if last_before is not None: + cancel, item = last_before + recipient = (item.get("operatorsName") or "").strip() + return recipient, { + "match": "post_portout", + "operator": recipient, + "cancellation_date": cancel.isoformat(), + } + cancel, item = parsed[0] + donor = (item.get("operatorsDonorName") or "").strip() + return donor, { + "match": "pre_portout", + "donor": donor, + "first_event_cancellation": cancel.isoformat(), + } + + +def _evaluate_portability(context: dict, portability_data: dict) -> tuple[bool, str | None, dict]: + """Owner at `opened_at`: recipient of last event ≤ opened_at, else donor of the first event.""" + try: + opened_at = _parse_opened_at(context) + if opened_at is None: + return False, None, {"reason": "missing_opened_at"} + + events = portability_data.get("portabilities") or [] + if not events: + return False, None, {"reason": "empty_portabilities"} + + parsed = _parse_portability_events(events) + if not parsed: + return False, None, {"reason": "no_events_with_date"} + + owner, meta = _resolve_portability_owner(parsed, opened_at) + owner_normalized = owner.upper() or None + return is_tim(owner), owner_normalized, meta + + except Exception as e: + logger.warning(f"Error evaluating portability: {e}") + return False, None, {"error": str(e)} \ No newline at end of file diff --git a/src/agent/nodes/validation_node.py b/src/agent/nodes/validation_node.py new file mode 100644 index 0000000..09a097a --- /dev/null +++ b/src/agent/nodes/validation_node.py @@ -0,0 +1,198 @@ +import json +import logging +import re +from langchain_core.messages import AIMessage +from src.agent.state.agent_state import AgentState, set_error +from src.agent.state.steps import GraphStep +from src.agent.state.step_helpers import set_current_step +from src.agent.logic.validation import validate_required_fields +from src.api.schemas.anatel_schemas import TicketRequestEvent, ERROR_CODE_MAPPING, ReasonCode +from src.utils.observer import trace_node +from src.utils.ics_collector import build_ic_payload +from src.compat.framework_observer import event +from pydantic import ValidationError + +logger = logging.getLogger(__name__) + +_COUNTRY_CODES = ['55'] + +def _format_msisdn(msisdn: str) -> str | None: + """ + Helper function to format msisdn by assuring to remove possible non-digit characters and the Brazil +55 code to be usable at IMDB and Siebel's APIs. + """ + digits_only = re.sub(r'\D', '', msisdn) + digit_count = len(digits_only) + + if digit_count == 11: + return digits_only + elif digit_count > 11: + + for code in _COUNTRY_CODES: + if digits_only.startswith(code): + remaining = digits_only[len(code):] + if len(remaining) == 11: + return remaining + return None + +@trace_node +async def validate_ticket(state: AgentState) -> AgentState: + """ + Validates the ticket requirements early in the graph. + If validation fails, sets the final_response with a structured error payload + and marks the state as 'validation_failed' so the graph terminates early. + """ + logger.info( + "Running validation node") + + await set_current_step(state, GraphStep.VALIDATION) + session_id = state.get("session_id", "") + + # Try to extract DataSourceEvent from context + context = state.get("metadata", {}).get("request_context", {}) + if not context: + logger.warning("No request_context found in metadata for validation\n") + await set_current_step(state, GraphStep.VALIDATION_FAILED) + return set_error(state, "ValidationError", "Missing context data", step=GraphStep.VALIDATION_FAILED) + + try: + # Pydantic validation of input payload + logger.debug(f"Parsing context into TicketRequestEvent. Keys present: {list(context.keys())}") + ticket = TicketRequestEvent(**context) + if ticket.customer.msisdn: + formatted_msisdn = _format_msisdn(ticket.customer.msisdn) + if not formatted_msisdn: + msg = f"Invalid MSISDN format: {ticket.customer.msisdn}" + logger.error(msg) + state["final_response"] = msg + await set_current_step(state, GraphStep.VALIDATION_FAILED) + return set_error(state, "ValidationError", msg, step=GraphStep.VALIDATION_FAILED) + + state["metadata"]["request_context"]["customer"]["msisdn"] = formatted_msisdn + except Exception as e: + + if isinstance(e, ValidationError): + error_messages = [] + for err in e.errors(): + loc_tuple = tuple(l for l in err.get("loc", [])) + + # 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: + 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 + }) + + error_payload = { + "title": "validation error", + "status": 400, + "detail": { + "messages": error_messages + } + } + rejection_message = json.dumps(error_payload) + await set_current_step(state, GraphStep.VALIDATION_FAILED) + state["final_response"] = rejection_message + return set_error(state, "ValidationError", rejection_message, step=GraphStep.VALIDATION_FAILED) + + logger.exception( + "Failed to parse context into TicketRequestEvent", + extra={"error": str(e), "session_id": state.get("session_id")} + ) + await set_current_step(state, GraphStep.VALIDATION_FAILED) + return set_error(state, "ValidationError", f"Invalid ticket data structure: {str(e)}", step=GraphStep.VALIDATION_FAILED) + + try: + # Call the validation logic + validation_result = validate_required_fields(ticket) + except AttributeError as ae: + logger.exception(f"AttributeError in validation node: {ae}. Ticket keys: {ticket.model_dump().keys() if hasattr(ticket, 'model_dump') else 'N/A'}\n") + await set_current_step(state, GraphStep.VALIDATION_FAILED) + return set_error(state, "AttributeError", f"Internal attribute error: {str(ae)}", step=GraphStep.VALIDATION_FAILED) + except Exception as e: + logger.exception(f"Unexpected error in validation node: {e}\n") + await set_current_step(state, GraphStep.VALIDATION_FAILED) + return set_error(state, "ExecutionError", str(e), step=GraphStep.VALIDATION_FAILED) + + if validation_result.is_valid: + logger.info( + "Ticket validation passed") + event("AGA.018", { + "status": "Realização de Check list dos dados obrigatórios: sucesso", + "type": "SUCCESS", + "session_id": session_id, + "tag": "AGA.018", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.018", { + "agentSpecificData.validatedFields": validation_result.validated_fields, + "agentSpecificData.validatedFieldsCount": len(validation_result.validated_fields), + }), + }) + await set_current_step(state, GraphStep.VALIDATION_PASSED) + return state + + # Validation Failed + error_payload = validation_result.error_response + messages = error_payload.get("detail", {}).get("messages", []) + + logger.info( + "Ticket validation failed", + extra={ + "session_id": state.get("session_id"), + "missing_fields_codes": [m["code"] for m in messages] + } + ) + + rejection_message = json.dumps(error_payload) + + await set_current_step(state, GraphStep.VALIDATION_FAILED) + state["final_response"] = rejection_message + state["messages"] = state["messages"] + [AIMessage(content=rejection_message)] + + event("AGA.027", { + "status": "Validação dos dados completos com Insucesso — campos obrigatórios ausentes", + "type": "FAILURE", + "session_id": session_id, + "tag": "AGA.027", + "call_id": session_id, + "origin": "AGENT", + **build_ic_payload(state, "AGA.027", { + "agentSpecificData.missingFields": validation_result.missing_fields, + "agentSpecificData.missingFieldsCount": len(validation_result.missing_fields), + "agentSpecificData.validationErrors": messages, + }), + }) + + # Set error in state for proper API propagation + state = set_error( + state, + error_type="ValidationError", + error_message=rejection_message, + step=GraphStep.VALIDATION_FAILED, + ) + + return state + +def should_continue(state: AgentState) -> str: + """ + Router condition to decide whether to continue the graph after validation. + """ + if state.get("current_step") == GraphStep.VALIDATION_PASSED: + return "continue" + return "reject" diff --git a/src/agent/state/__init__.py b/src/agent/state/__init__.py new file mode 100644 index 0000000..63bf045 --- /dev/null +++ b/src/agent/state/__init__.py @@ -0,0 +1,9 @@ +""" +Agent state definitions for LangGraph. + +This module contains state definitions used by the agent graph. +""" + +from src.agent.state.agent_state import AgentState + +__all__ = ["AgentState"] diff --git a/src/agent/state/__pycache__/__init__.cpython-313.pyc b/src/agent/state/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6dd702e1a33aa1838d636eee8527581eaf280ae4 GIT binary patch literal 415 zcmZWlyH3L}6t$C}R)MY{!D6vxi6vMOQYXYyNMXNn9Vc}kab>%pva#_4d;~wihh#!x zVgps|*baz+E7?BwJ@=gJyB-Wu<l<nsKY4NIo4@=<^aIwr4m_a(X;k1MI>%>`jtDAZ zigjEk*~Yz0hqY)dF_u|DN>Ry1TB#aCDy7K<Yw8ot7UMikZ^qJ)xhj`4LAYuxlaBPi zY-vPEMk`{+g0LW6cLMLerwq59Vd4FB?4GW&r0oHeiUYKJM)N$B<w3f`5O837Dx1v! zp3&M1ww%rzOG|oLoRVv;CW2c-g922V&BdM4Q&3#e5#v);Rgw#E?cWM*WvTu+)H!!` wG`LX!KMH_+v1<EA-N%o$pE-8RY`g6v#u$G_2~NJY5I%g}+<D9&db;mY-xRfd#Q*>R literal 0 HcmV?d00001 diff --git a/src/agent/state/__pycache__/__init__.cpython-39.pyc b/src/agent/state/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c5b384bca2e97596354da4609bbaee1361f6aa1f GIT binary patch literal 401 zcmZWly-ve05O&f~P()t<7K^P*EOkbRN=Ph-rLbf<U2P{ZP3+2-sw!{9OYk6BnRo>z z9A`n|B!9k>zMs?KVv(~@tM$#>XZXLy;cum3xDSu60w`88m8i5$y0lCare<|g&Q!Kx z%d>ZruWN04PCfRTD_t3DdgCnd%6a~Twtn=uZ;CvBX$<kssY9!|bhbwmNdILA(u!|S zyl*teXkAYR-(eNDOol0NGv@hm&*x<}oC7E)0fuYxvWT)GT2DR1KC(wDwk^O1^YfsN z@Y9G_(w>C$hI)h6HI5py!y}3ZceJfTMIzcl<+0O8=Xc<wft8T1+anQdQSMw-8L36s eVIqr|D*(o$02KSvaB(}G;4TT_lm*D<m+3Fbx_K`E literal 0 HcmV?d00001 diff --git a/src/agent/state/__pycache__/agent_state.cpython-313.pyc b/src/agent/state/__pycache__/agent_state.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..512a35b8873cedec6afd6c71a209720d8feb9cf1 GIT binary patch literal 9896 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 literal 0 HcmV?d00001 diff --git a/src/agent/state/__pycache__/agent_state.cpython-39.pyc b/src/agent/state/__pycache__/agent_state.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c4f15afef4dd36bd1a81e6deb67c79ba8517116 GIT binary patch literal 9232 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 B{&)ZY literal 0 HcmV?d00001 diff --git a/src/agent/state/__pycache__/step_helpers.cpython-313.pyc b/src/agent/state/__pycache__/step_helpers.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..07d7d96998e9e36a6955da45fbb7228e2e3ce755 GIT binary patch literal 4619 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 W#XUH_j@bMt)tEm%@^5=>YvO<PI-zm^ literal 0 HcmV?d00001 diff --git a/src/agent/state/__pycache__/step_helpers_emulator.cpython-313.pyc b/src/agent/state/__pycache__/step_helpers_emulator.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14532635640d52fcf0e6cf6321b0aa91027d87aa GIT binary patch literal 3638 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 literal 0 HcmV?d00001 diff --git a/src/agent/state/__pycache__/step_notes.cpython-313.pyc b/src/agent/state/__pycache__/step_notes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c9e90ef2bdf9e095af2b81f47db57a57632a835e GIT binary patch literal 3361 zcmaJ@OLNm!6qX$&fFXng2yvbl@&H5PFg!X_+JP#vLIjSJu?!t%GF5J*n^<5==1NKj zuUK};qMhlM>7vuus?$wpy7C{eS9G-Mw5#sa((ZfiRUA7+X%@EpednC-yzW)r_V=d* z_`4xpdio1I{lR{;|4!~byfu@8@P#ldXaW_qgqD1mc#xz?BwQCZlEuEZdFNZ}(G$QN z?;!0bP2>#MY-7>4*2|ew2T}G*>*LJn80Hvf&crasIWrW)oZ!s47$(J;;TYy5XGUU} zQ=A!%Vfr~U9>bjG%w!BRz!?<7oZ-x+7-o<&Gcn8%XXav<vz)mS!<>V;U5&xd13n*v z4+EZ!!7l)QJq91S-m4^NdSi4WxiNMt*$XBc<68PMeD92A#{BORf{-eb9n1CT3*>C0 zvR}pa0wvhQTNQ$A$0IJP5sL0pTq|oHsby0s1si1sy5PpmT)>f6#vXFZj$bhmu3<_H zaI+m$2CK%8)G{1I>|G0+4#EzSSGClY%^GzK;<}c-lVwty8DtWpf+<G$xo;uY$EZqd z7w?d27BO`fVc#V-%gQ7mo2r5oavNVc$h9iOHY~?=T=e_P&r#!N&#E9u1S5}P+qD{B zH-6%Nh=LY|z@CGi`%oow_Zr_;Q!KOv3Gy~LP^z@Aj~d?~!?6wQ5gWP#&sF4ERY(F? zYpCKF7{o}$L7Oh|bi=0<V(F|ioAaq{heBT=Ftiszuc2UM*#;#n32=lq84w1XI~~z^ z^EVNAF%c8e`IC(}lL$`iPotiZ%ecyV%KE(pxz9sj%doiGUa+Xt`rI<El%ccS)`$6F zilA2}c*7@T5JmE*KqNA?G1|oy%f!$+Xi>+eK3*hl&4IL-E{_E*VLYq`sZS|3wV<;G zVKwi?Wkss1xutA2TOZ$x&sHL`gif+?Fav)95-1W7uJbB(3AMH@qn!jn^7=9|Er55x zhp>2=SV34eq}2$4ZIx_XdEr@x>)(aoZR<VJI6JfeeKaj7(r)~)OZbSY4o|XvOLlD6 zuXvpvn^7beLC3bROJF(KvXqGt5s;&Q2ZWbmIA+MBK?jc(_1VSJ)f8+uURSI=C2Sj{ z5>1&YNnpA^z@rSirkO?Uf+!>o`&cG!)@S$95rUM!NyAmkhK*rkYnK-*ddN6d?Y;j~ zgDU|piLM>kUn-{j!BOSHY94fqFO&^N;;Y1NAIpTfvm?Sr?>1hu+1DJ(?hrWXd`UYy z&E)TO%d-IdHi89+_lT>$J}zy+ozRKP&WdJ{Em8r6`bhhX<1IqrknEH7+fm2iNMi^< zyN}wQdz@U5(n`v@URWzBS|KwL9Mg5vF?2mR?@}WRm#yt(;b_CrV%L{Td!WK#U|A{2 zD{A4sUQ*?UN+}qA2hf)#HLv7?!N*cw%}FJ7t<Z)}%96aI=*w%53b|l_opD_$6jga; zRVhHAcYwCnc{K;LT6&^CR*LGfD)X4Zpti0k@=AAvi9?Y7s31Lte59qk5~Lp%);@;R z?koC|q$%B=vqw?~A`Hn=L00mtdZ{4gpJ=KUqz_~5%!f<rsxGgsuIDASQ0ll$#N%zZ zbWxG>lBVs~+VMn3Qf=p<Qc;mgtf6{NkyQ=aAEXasZSz4*RhE>zt`+sQb)^861!oRI zFv^jG2rEAtG8NfMh^A$DK@Ysof&pE}HoUN~XThn_^>2i?U?Bu#v1)sZ=HjZRqIK## zC5GoN@(t6sDIBLy9r{dn3~O-<8_%3=IO0Ty`w;GS_8#uoEW&0+ev5oF;gi&&H)OW< zA~^nG)iM1Fxl5-(75=Fa-0$Ht>`5dNe<xE3@!t_4F(3A!ndEO`{~K|lB~CP_9)!IB zgndHq={Mp`OPp!W6~bc-I?kb~mN?bCxD=jXP>Mt8mY8miy&s-r&>0Rzu|5h188p-t zD;J(+&^ZoGx5Vk@Y$-g?pkWS0;q&1I290p&T1&jvT&RX=290v)a!b72%;@15gT^^@ zvnAea-hLiVFz6D8P)kJ3OAo?n2F-LiR>D~Z&2cEIu^3)v&=n3vidL9m(A6$S9A0D4 zJcqI^G26Ue4Hp=6gF}(xC%>|GMmav+>ts~dJ<d$F#K|VQ!=L9`;#~8J{?|PS@-p>V W?p69r`PF3W6#7R%i6p%UQ~M9_`Ot{~ literal 0 HcmV?d00001 diff --git a/src/agent/state/__pycache__/step_notes_emulator.cpython-313.pyc b/src/agent/state/__pycache__/step_notes_emulator.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..510ec60199186751bb076ebea25b1a1563db73f5 GIT binary patch literal 2726 zcmZ{l&r{n*6vt&F65<2`;g=x{S$+lx*rd}=E^Vid5yXuFS5nh7WkxL08Y{6S&q|^b z{}sLT-0Aet_T1@Tz@b<9l#WiB>8UrhwD-PUTe1VHXKYEI@7wp@zV}wX7#_|D@OiJW z_%gUI2!F7L=yRicb5TwU!neYTpa@h@QcC)1>eDn$BVk)OO3T+G^WIe%&{Cib^+<H5 zh+N?@Bi6YpgIu}wE@e;3HLi>#DA&0%nxG7E<xYZfgDVpWN`@;_3Cc~bWD}HIT$xQ! zhPg7App0;3K0&$76_lWia%C|=8RN=wf^vr|s|m_DSJo1g3FzBK0-prFnZT!j=Mwnz zwkV}&_GsoPeKh-idJs&G?kd^)a6O$_pKbm2#A{${ixO<$lR7~ab?dQ=8pLt%DREGf zP;@LcygGJmx<_%drnsbejH)(8#eD@GH>s_Iv1y&=EZZf=xlD#xI0&AQLG(JN#6UGd z34%N1m~~>gIrI$IJ#f#^E7Ek4X>}u?5FL9CL2iv?SOCYPRSXdrXduI+M0e}2k!v%6 zY{-vgmdpIf_P(A9#;zpm-2;Ww^B>QDupuc#iPN+l7eiQ6H(}=<tH&;`*D%6%mr_L7 zzOO85-IIy#vA~vLBfW+j7_K%oPfZKgbGck=CH4f_a!ZP?T6zy5Ce*<+xT7;n&GYZi zi0L5v1e{^_{1<9s2O02XS^P>IaPF=1RsUmau?;pUA(mbPzfNoOvFGUAze^fT=pt0I zzW|if$r-i)v&3iO#822VisD5co2}Sa&2)Hny8Vtf2in^(S;u*%>@Z$-*jw3}-|Lnz z?go@zubVI*ge5Z_uRR4r8aJ&q*DG4Ql$jWq)n^5w%29ti7*(OOwudZ?&Un3fPd#=Z zWpWC8>9i8VET2XFhK{lp8!%gt%L5rZx@Xm3dT^Ww>MVffH%Yx`@39w3oIC9Xpj~So zx;4H_U~u~8hXuJ??9Nh^iEOs)2FyP@UUpw=xY{>Rt%W#PSBhrRvXNqv6H*Vx6;-Ne z2ZySpXwv@Y<$`)BAFR&>*EP+sbxjNA9jfQx#KYNya{&!-*df;V34rrO2VhS@R+f~? z;ejG`eUuIlG(|1QYA}<a>=sI8X(!0+N^0?mRxBt|FxI=*y7K6=Lb<e4P$jKURKcMV zOkXCE=^a^8<&yMF(o|``0(+1Y7WguY%tvGCNlAhIei=+(CXwmbo^&9|EQx3zLG}uY zjpO`!##c~eJY1+$<U>e#M=tED!3e)-UAPlWydy`8GR5*C6q%RoCi~Sm*a^mZLS&m& zO44JgtSK@#Djn=~6)LipiH51W!2pbSa9h)`1#e~SnzrR=+RwrTSO|fYZ&+^L$nPs9 zRH61uc)>b(J_=fu;s*KJrmr+xH}fZ0e`QyzrcN~7w$2E3O~dZl<Y9z5>+*br;FmNt z_NUOBqg?ZKF!W)=HoQ9dn6iJ-6nvfU;5QsdrBZ*V#gzE(Eg|($cx~>3^zY*28!^`w zbN=>c;Q)ZQ4CLEl-hcQa90U*!34>E_MAR0MzgP@!FqGlwep|fnuUEpG42^KK+7?&+ zwXee442^QM)fTt>{EKjmp$U%G+TxnO@jRSlXo{mw#Bw;zP`1z4^Kgct**;%S!@CU4 zbJR(q6y9S9an#vPDO_M^vCnERTw-Xs&+3bCg`qW$mfGTyzp@{$Gql0ca$8*XSC#Mq zLz^6Jw8ag7vm9<Qw9QecIFEQS7uw>2zw`;e?<BX=S7ha{N3hHn#=lYh*#|!<{_J*p O^5H+iTq;cwH0nPho%$sJ literal 0 HcmV?d00001 diff --git a/src/agent/state/__pycache__/steps.cpython-313.pyc b/src/agent/state/__pycache__/steps.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..435000428e2efea47a3c41d018340191b7460e3b GIT binary patch literal 2980 zcmZ`*%~RV(6qi8(Vj#p23?^~1d<9Gio@pnCCTV7j5ujjf#_J@LPG;7!v<3xRa^K2i z=!sL$Jv8@T``>uli$|wUd&;eyH1|GPQf!0qjMlrq_p$G5-|nZ$$*6?CuQN;U1kV2U z7UAzkF!{8BlMfP=K%x;kbP#zl1VcocmzE?tydY8e?a<?4pBILYZ=^<=6GM^|&2q~D zt!Dnn4RU7dTBTXj;GEc1Qr4RQNM*v|jWRjYYE3RkQy`<JgDnJcPB_tyk%b(C);QUU zM)O+Tw2lchYg{~uJ|g93p73+VEim<yI<l5YdH~9*npxvUnN)2cU1nn01{YgBiZn~B zoeNX+stK_^B_Y^VQ9yISUy^n7#xyj?w5?52Id5n}Y!Y45PxvOOaz{VeB*NqsUSk_6 za>{w5<7>@yIL0bUI$DUO)%jCmTeWji{<8O(2Bu~?f@pxjF$|2>`x9=FI@c@=O|@CW zFqNqTiq))_JGuoD>lF@OaJ5Pe#RO^8G}9u&0hAHsuZv%ZZr2;ATi6!rK1Ql=NN7;V zx6ZeYw>Ak%b`nCsZlc8^MKsGGFK8i81V{5Q+a1?^&)(Z`io0w@KYG=XinybJb-G_I z{ul`spMJ&32kET@5iGSK8i8Rt1Tr0l8&rl7dILu32#nEDxJkz#N^inAjlu*She<jC zx9B8H(OYnvPC<;`hG`mu89EJj=nTx#I}oR{Fh}DsPv;;(=V5^+;4WQ&d-N_O={>kl zlkkAv2SOjfA|<dy7h#z$!3tf5Rk{KX=_;%pKTNH;H}|}fq*#3)V`#BEZd#_(ah)6P zR42#iG;>V-lsj(x5)h=*jdttAjdkbDD;U(%3;7l7PWaWtI!C}A@6;5-y3$X>Mb&Ov zXo6WcDm~}IO<V$)m+%^QaGUsLQn1&oY2b-6opW}EX|0-iC#!BuG&t8!dX*=yK&)wL zXL#tmf^!q6mi->j-!W$uP4HgH)oV$88YUZH>zo^`Q+V#Qu30*-c@Yi|qKoJ2GRAx; z%tB%SN8oDQG3$Oru=1U*(&S(~%wGbyUc+2{2~JmX`C3v><jM6+fnhV)vEFN7^tqZG z0QP8I7(@>AO*_Cf$Lo~L;JPWWL%E5oSm<EPFJKmW=@n(rxdbq~!M&#A-tLExX}%xv ztoIH0P0FX%-7&_z?#7rKWvp%+*w1l3!PpN?trk$G7^|8f9LzGeY$T7Xd9|cwa_aZW zjytuhl(PG3evg&Z?155p<Cg%on^AMf7=48jJDHMN$h)!boE>E-MRG3`NK9<M@rbPW zGN({?Je$exD-6HvLAcm0yv(Bs>fz2dQ}RVMyML(UOK##4z_NwIqnuL0ZQ_?n!CtfK z4oa$}H|&*CRCm>EU?@FOl<a=5^5hkWz07A`p_7^IoZ==9@`cyv@1DZ8GgRr7T)mdm zr(yD_SjZ~M4%;hcj`rQ@Y$l&oa$bZp`AqH&RjEs^V$6raEF=bS1g^$Q>Y*QzjG8ZX zl_m${Vg3?DC7Z)^d<jlha`{?PPvpt<Oo3rDyM^NGOmU~r)#L!MN9)2Ma-eUzR8%r0 zuOirvl2xf!d5No7=wQq*U>17m6(v=bZ6(KOkrj@VJR0WS?uU?Rz8~?d4~vT7n+kii z&NK&a_zGUqf-&$OaG&s#+;X~ZIcXz(NL6wK_B*aSBHejuu`1xX^sa1X>t?#5>8ExT zJ3q(vV&N^|n1<cuNej?@xwN+-$B{S0R^uGJS7T@JE=Tb@4nIkMPCvLfXvfzs3hm_M zmb}o8B`;pI=f7&nv%xsCc#&&&xV+(?5ovB6naR_ZoD4+nebbT?0ryI_B`*flP1=%E zC@{NnaoA2gYRU2Tn%a_A`h<H0HVQ3yE#O&5x8%9DPhI@3CEpJ;bT&q%iOsHwbE%em zr~TmDKjeErLnreo*&Sufuyt%xGRKz}cu!$J3SQWK))LO#v1fk2dk(L0={<YIT^#-y ejzl8=4vk0T&kIr{@!!Zs<jK#YpC$b32>%a5YVh~~ literal 0 HcmV?d00001 diff --git a/src/agent/state/__pycache__/steps_emulator.cpython-313.pyc b/src/agent/state/__pycache__/steps_emulator.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..85dca0124c14c3e20376626f9aa1b87b4f51f0aa GIT binary patch literal 2020 zcmZuy&2Jk;6kppLCw1cZqj9|XUJ9snL9swWsDO}6vT5BWwzbc#gj8v=S&x%#Y_EB{ z4hp9n5dT3X{sr#*A(pt9IUykqy;TZG9C`a?W4k`g&ip=R-h1PHdz((DB>H_?x%cYd zq$K_6Eu!B-IJtdDlh+bRC_xP3&tgyG7*{1lx+g(mQv&&A{If)$#l-nSDfvVD)Epr4 zp35AU_fOR;+ceGw9Rsh`wvDQL!Bw3*eY@vyRqGPr5_*nC|3ZCm$@{MA@V<eD%hfj8 zUDdqc=4Hon-20At>h0+4_peV&`)|BMOEIwsE^56&*9?LP##p$x{gEcGrI!-MD1$h} zFadFtA%P1ZV-gl{5t6tBi+Bf?Fa>vT8B(|c%b120T!l1dU=_2F!5m~U4>`OGd0d0L zSb#NLhXNL19Th0z1}L}*8@L6VxD8vl1KYR@JGckCl-*uQ6}c$c$6l^LnY^K*OG3f0 z8jSnZu<RaH&Oldeyn|^QR)@Dls?A;Vf|-WHMQ$=@A)UQ4I#!En$PCk^4INROMIvbi z?SadYwYX_HBo%q&E?WGGGnaS!bUobhBALY^e|Ah=&?VV;EsC>9B+Z@k9`|mRMTrSz z4jPNc0Z+tp5bGO`L*-EBnXnS+K1YWFV=Xk=uE_Wg7J}qlo8qIx(?-9KHkBZ7tp=_T zB-0cfnNnuQc6e|n%ihf}A83i(gov~`$Kq$a!yNQQR_|P7ry(z^X@o?f{THcLdQU7d z)-$@CF_B`dYqtg+Z@$9VcLSpnG8x9&7CLUn>hYdUw4!U!sMkPa+R5|dO0!z8F=$rw zrr3A~b5yAwYlk9rq%{wou!9O{A~%_{kj}oS99Iu3O^sC!ybVDVXOT#n(d*Bfn$8Zj zgDQ|z<aMp7SG5-!YicJA3P=Mll36VBXUEi&Dm3f*x1u<UMAF=2t)}T-R8e9=nS;jS zaljMt9K;$G04j$v&xDmoH#8k6Fm|X{j+!FlKUfHob8U)`4o_Da4ZTh!2wbayD+I|j zMMtL8!Ev2#(Z7>rZ)84jC~^}b(&nJ5eWM*S(0x(0_E==6Aup?GghY|Oz`Ta!S|&4G zY6EA~FC513ed=DfUZWLOx!ZHgt?~&})dt$Hxam4&gI;ZywUPd!-`jY}Y||>A8Rn(k zZd)e38+PxCqieP7iKgtJ8TiWHft*I__hDbOks3dG-z9(WdZ+U%4L?bL>^&OF+avYM zp}g}pDdl%>o{p4{hH`-x%EO_&HroAsC~wiK_~52akIns|ydLOB#nS%;8y}Nx6zu13 z5zc*toP`f=PDbnZZ|bA1PlmD*A%zcbYGcGdKfibLY#c<eYjD!uqu=Ch)S@(MC0Q&o z*0N1{Ib}{CGW0&DL7rZP1L+aX#L`#6TmCgxY3Y6AoFWZx6R}wAcd7H2r2L&M$G-S^ K@gIpEzVSbgKb&a* literal 0 HcmV?d00001 diff --git a/src/agent/state/__pycache__/transitions_emulator.cpython-313.pyc b/src/agent/state/__pycache__/transitions_emulator.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2085c6f4bb595871885c33f5062895013b2e1b22 GIT binary patch literal 2635 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 literal 0 HcmV?d00001 diff --git a/src/agent/state/agent_state.py b/src/agent/state/agent_state.py new file mode 100644 index 0000000..2c0acf3 --- /dev/null +++ b/src/agent/state/agent_state.py @@ -0,0 +1,302 @@ +""" +Agent state definition for LangGraph. + +This module defines the state structure that is passed between nodes +in the agent graph. The state maintains all information needed during +agent execution. +""" + +from typing import TypedDict, List, Optional, Any, Dict, Annotated +from langchain_core.messages import BaseMessage +from operator import add + +from src.agent.state.steps import GraphStep + + +class AgentState(TypedDict): + """ + State maintained during agent graph execution. + + This TypedDict defines the structure of the state that flows through + the agent graph. All nodes receive this state as input and return + an updated version as output. + + Attributes: + messages: List of conversation messages (HumanMessage, AIMessage, etc.) + This is the primary way to track the conversation flow. + + current_step: String indicating the current step in the graph execution. + Useful for debugging and conditional routing. + Examples: "llm_processing", "tool_execution", "routing" + + processing_notes: Incremental log alimentado por `set_current_step` no + formato "[DD/MM/YYYY HH:MM:SS] mensagem\n…". Espelha + o campo `processing.note` enviado ao CMS. + + tool_results: Optional list containing results from tool executions. + Each tool execution can append its result here. + Format: [{"tool": "tool_name", "result": "output", "success": bool}] + + final_response: Optional string containing the final response to return + to the user. Set by the final node before ending. + + metadata: Dictionary for storing additional execution metadata such as: + - tokens_used: Total tokens consumed + - execution_time: Time taken for processing + - model_name: LLM model used + - error_count: Number of errors encountered + - retry_count: Number of retries attempted + + session_id: Optional session identifier for conversation continuity. + Used to maintain context across multiple requests. + + iteration_count: Number of iterations through the graph. + Used to prevent infinite loops. + + error: Optional error information if something went wrong. + Format: {"type": "error_type", "message": "error_msg", "step": "where_it_happened"} + + Example: + >>> state: AgentState = { + ... "messages": [HumanMessage(content="Hello")], + ... "current_step": "llm_processing", + ... "tool_results": None, + ... "final_response": None, + ... "metadata": {"tokens_used": 0}, + ... "session_id": "user123", + ... "iteration_count": 0, + ... "error": None + ... } + + Notes: + - All nodes must accept AgentState as input and return AgentState + - Nodes should update only the fields they need to modify + - The messages list should be treated as append-only + - Use metadata for tracking metrics and debugging information + """ + + # Core conversation data + messages: Annotated[List[BaseMessage], add] + + # Execution tracking + current_step: GraphStep + processing_notes: str # log incremental "[DD/MM/YYYY HH:MM:SS] mensagem\n…" alimentado por set_current_step + iteration_count: int + + # Tool execution + tool_results: Optional[List[Dict[str, Any]]] + + # Output + final_response: Optional[str] + + # Context and metadata + session_id: Optional[str] + metadata: Dict[str, Any] + + # Error handling + error: Optional[Dict[str, Any]] + + # Memory/Cache state + cache_found: Optional[bool] + + # Bypass flag for cancelamento/reclassificação/reencaminhamento validations. + # Set by bypass_rules_node when complaint.actionType is "reabertura" or when + # there is prior history for the same complaintProtocol that ended in + # opened_cancelation_sr / opened_reclassification_sr / opened_forwarding_sr. + bypass_treatment_validations: Optional[bool] + + +def create_initial_state( + user_message: Optional[str] = None, + session_id: Optional[str] = None +) -> AgentState: + """ + Create an initial agent state. + + Used in two flows: + - Chat endpoints: pass `user_message` to seed `messages` with a HumanMessage. + - Ticket processing: omit `user_message`; the agent operates on a payload + stored in `metadata.request_context`, not on a chat message. + + Args: + user_message: Optional user message. When provided, seeds `messages` + with a single HumanMessage. When omitted, `messages` starts empty. + session_id: Optional session ID for conversation continuity + + Returns: + Initialized AgentState ready for graph execution + """ + from langchain_core.messages import HumanMessage + + messages = [HumanMessage(content=user_message)] if user_message else [] + + return AgentState( + messages=messages, + current_step=GraphStep.INITIALIZED, + processing_notes="", + iteration_count=0, + tool_results=None, + final_response=None, + session_id=session_id, + metadata={ + "tokens_used": 0, + "execution_time": 0.0, + "error_count": 0, + "retry_count": 0 + }, + error=None, + cache_found=False, + bypass_treatment_validations=False, + ) + + +def update_state_metadata( + state: AgentState, + **kwargs: Any +) -> AgentState: + """ + Update metadata fields in the agent state. + + This helper function updates metadata by creating a new dictionary + to ensure LangGraph detects the state change. + + Args: + state: Current agent state + **kwargs: Metadata fields to update + + Returns: + Updated agent state + """ + new_metadata = dict(state.get("metadata", {})) + new_metadata.update(kwargs) + state["metadata"] = new_metadata + return state + + +def increment_iteration(state: AgentState) -> AgentState: + """ + Increment the iteration counter in the state. + + This should be called at the start of each graph iteration + to track how many times we've looped through the graph. + + Args: + state: Current agent state + + Returns: + Updated agent state with incremented iteration count + + Example: + >>> state = create_initial_state("Hello") + >>> state = increment_iteration(state) + >>> print(state["iteration_count"]) + 1 + """ + state["iteration_count"] += 1 + return state + + +def set_error( + state: AgentState, + error_type: str, + error_message: str, + step: Optional[str] = None +) -> AgentState: + """ + Set error information in the state. + + Args: + state: Current agent state + error_type: Type of error (e.g., "LLMError", "ToolError") + error_message: Detailed error message + step: Optional step where error occurred + + Returns: + Updated agent state with error information + + Example: + >>> state = create_initial_state("Hello") + >>> state = set_error(state, "LLMError", "API timeout", step="llm_processing") + >>> print(state["error"]["type"]) + 'LLMError' + """ + state["error"] = { + "type": error_type, + "message": error_message, + "step": step or state["current_step"] + } + + # Update error count via non-mutating metadata update + error_count = state.get("metadata", {}).get("error_count", 0) + 1 + state = update_state_metadata(state, error_count=error_count) + + return state + + +def has_error(state: AgentState) -> bool: + """ + Check if the state contains an error. + + Args: + state: Agent state to check + + Returns: + True if state has an error, False otherwise + + Example: + >>> state = create_initial_state("Hello") + >>> print(has_error(state)) + False + >>> state = set_error(state, "TestError", "Test message") + >>> print(has_error(state)) + True + """ + return state.get("error") is not None + + +def get_last_message(state: AgentState) -> Optional[BaseMessage]: + """ + Get the last message from the state. + + Args: + state: Agent state + + Returns: + Last message or None if no messages + + Example: + >>> from langchain_core.messages import HumanMessage + >>> state = create_initial_state("Hello") + >>> msg = get_last_message(state) + >>> print(msg.content) + 'Hello' + """ + messages = state.get("messages", []) + return messages[-1] if messages else None + + +def get_last_ai_message(state: AgentState) -> Optional[BaseMessage]: + """ + Get the last AI message from the state. + + Args: + state: Agent state + + Returns: + Last AI message or None if no AI messages + + Example: + >>> from langchain_core.messages import HumanMessage, AIMessage + >>> state = create_initial_state("Hello") + >>> state["messages"] = state["messages"] + [AIMessage(content="Hi there!")] + >>> msg = get_last_ai_message(state) + >>> print(msg.content) + 'Hi there!' + """ + from langchain_core.messages import AIMessage + + messages = state.get("messages", []) + for msg in reversed(messages): + if isinstance(msg, AIMessage): + return msg + return None diff --git a/src/agent/state/step_helpers.py b/src/agent/state/step_helpers.py new file mode 100644 index 0000000..ce63565 --- /dev/null +++ b/src/agent/state/step_helpers.py @@ -0,0 +1,89 @@ +""" +Helpers for mutating `current_step` on the agent state. + +`set_current_step` updates the state and, when the step is in the +persistable whitelist, emits an incremental `ProgressEvent` to the OCI +stream so the CMS can reflect the sub-status while the main status is +`processing`. Também acumula uma linha humano-legível em +`state["processing_notes"]` para cada step registrado em STEP_NOTES, +formando o campo `processing.note` do contrato com o CMS. + +Producer + transactionId are injected into `state["metadata"]` by the +route/worker that invokes the graph (`_oci_producer`, `transaction_id`). +When either is missing (e.g., LLM-only test graph), emission is skipped +silently — the helper still updates `current_step` so routing keeps +working. +""" + +import logging +import asyncio + +from datetime import datetime, timezone + +from src.core.config import settings +from src.agent.state.agent_state import AgentState +from src.agent.state.step_notes import STEP_NOTES +from src.agent.state.steps import GraphStep +from src.api.schemas.anatel_schemas import ProgressEvent, ProgressProcessing + +logger = logging.getLogger(__name__) + +# Steps that are pure routing transitions, bypass-only or initial state — never reach the CMS. +_NON_PERSISTABLE: frozenset[GraphStep] = frozenset({ + GraphStep.INITIALIZED, + GraphStep.PROCEED_GRAPH, + GraphStep.TICKET_CLASSIFIED, + GraphStep.VALIDATION_PASSED, + GraphStep.IMDB_ENRICHMENT_COMPLETED, + GraphStep.RECLASSIFICATION_ANALYSIS_COMPLETED, + GraphStep.FORWARDING_ANALYSIS_COMPLETED, + GraphStep.CANCELING_ANALYSIS_CANCEL_TICKET, + GraphStep.BYPASS_RULES, + GraphStep.CACHE_CHECK, +}) + +def _append_step_note(state: AgentState, step: GraphStep) -> None: + """Append `[DD/MM/YYYY HH:MM:SS] <mensagem>` ao log incremental do estado.""" + message = STEP_NOTES.get(step) + if message is None: + return + timestamp = datetime.now().astimezone().strftime("%d/%m/%Y %H:%M:%S") + entry = f"[{timestamp}] {message}." + current = state.get("processing_notes") or "" + state["processing_notes"] = f"{current}\n{entry}" if current else entry + + +async def set_current_step(state: AgentState, step: GraphStep) -> None: + """ + Update `state["current_step"]` and emit a progress event when applicable. + + Awaitable so the OCI send completes before the next node runs, preserving + sub-status ordering in the stream. Any emission failure is logged but + never re-raised. + """ + state["current_step"] = step + _append_step_note(state, step) + + if step in _NON_PERSISTABLE: + return + + metadata = state.get("metadata") or {} + producer = metadata.get("_oci_producer") + transaction_id = metadata.get("transaction_id") + if producer is None or not transaction_id: + return + + try: + event = ProgressEvent( + transactionId=transaction_id, + processing=ProgressProcessing( + current_step=str(step), + note=state.get("processing_notes") or None, + timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + ), + ) + await producer.send(event.model_dump(), key=transaction_id) + + logger.debug(f"Sent progress event at step {step}") + except Exception: + logger.warning("Failed to emit progress event for step=%s", step, exc_info=True) diff --git a/src/agent/state/step_helpers_emulator.py b/src/agent/state/step_helpers_emulator.py new file mode 100644 index 0000000..a30ae9b --- /dev/null +++ b/src/agent/state/step_helpers_emulator.py @@ -0,0 +1,64 @@ +"""Helpers to mutate `current_step` for the Response Emulator graph. + +Mirrors checklist's `step_helpers.py` but uses `EmulatorGraphStep` and +`STEP_NOTES_EMULATOR` so the two domains stay independent. ProgressEvents +are silently skipped when `_oci_producer` or `transaction_id` is absent +(e.g. local test runs) — `current_step` is still updated for routing. +""" + +import logging +from datetime import datetime, timezone + +from src.agent.state.agent_state import AgentState +from src.agent.state.step_notes_emulator import STEP_NOTES_EMULATOR +from src.agent.state.steps_emulator import EmulatorGraphStep +from src.api.schemas.anatel_schemas import ProgressEvent, ProgressProcessing + +logger = logging.getLogger(__name__) + + +# Internal transitions that should not surface as CMS sub-status. +_NON_PERSISTABLE: frozenset[EmulatorGraphStep] = frozenset({ + EmulatorGraphStep.ROUTER_DECISION, + EmulatorGraphStep.VALIDATE_RESPONSE_PASSED, +}) + + +def _append_step_note(state: AgentState, step: EmulatorGraphStep) -> None: + """Appends `[DD/MM/YYYY HH:MM:SS] <message>` to the incremental processing log.""" + message = STEP_NOTES_EMULATOR.get(step) + if message is None: + return + timestamp = datetime.now().astimezone().strftime("%d/%m/%Y %H:%M:%S") + entry = f"[{timestamp}] {message}." + current = state.get("processing_notes") or "" + state["processing_notes"] = f"{current}\n{entry}" if current else entry + + +async def set_current_step(state: AgentState, step: EmulatorGraphStep) -> None: + """Updates `state["current_step"]` and emits a ProgressEvent when applicable.""" + state["current_step"] = step + _append_step_note(state, step) + + if step in _NON_PERSISTABLE: + return + + metadata = state.get("metadata") or {} + producer = metadata.get("_oci_producer") + transaction_id = metadata.get("transaction_id") + if producer is None or not transaction_id: + return + + try: + event = ProgressEvent( + transactionId=transaction_id, + processing=ProgressProcessing( + current_step=str(step), + note=state.get("processing_notes") or None, + timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + ), + ) + await producer.send(event.model_dump(), key=transaction_id) + logger.debug(f"Sent emulator progress event at step {step}") + except Exception: + logger.warning("Failed to emit emulator progress event for step=%s", step, exc_info=True) diff --git a/src/agent/state/step_notes.py b/src/agent/state/step_notes.py new file mode 100644 index 0000000..876c6e0 --- /dev/null +++ b/src/agent/state/step_notes.py @@ -0,0 +1,48 @@ +""" +Registry of human-readable notes per GraphStep. + +Each persistable step that should aparecer no histórico enviado ao CMS +(`processing.note`) declara aqui sua mensagem. Steps ausentes deste +mapa são silenciosos — útil para transições de roteamento que não têm +sentido humano. + +A mensagem é concatenada com timestamp local pelo `set_current_step`, +formando uma string incremental no campo `processing_notes` do estado. +""" + +from src.agent.state.steps import GraphStep + + +STEP_NOTES: dict[GraphStep, str] = { + # --- Bootstrap / orchestration --- + GraphStep.FETCHING_TICKET: "Chamado recebido, iniciando processamento", + GraphStep.FETCHING_TICKET_FAILED: "Falha ao recuperar os dados do chamado", + GraphStep.VALIDATION_FAILED: "Falha na validação do chamado", + GraphStep.CACHE_FOUND: "Resposta recuperada do cache", + + # --- Enrichment --- + GraphStep.IMDB_ENRICHMENT: "Enriquecendo chamado no IMDB...", + GraphStep.IMDB_ENRICHMENT_FAILED: "Falha no enriquecimento IMDB", + GraphStep.IDENTITY_VERIFICATION: "Realizando verificação de CPF divergente...", + GraphStep.SPEECH_ENRICHMENT: "Enriquecendo chamado via Speech Analytics", + GraphStep.SPEECH_ENRICHMENT_UNAVAILABLE: "Enriquecimento via Speech Analytics - indisponível no momento", + GraphStep.KNOWLEDGE_BASE_ENRICHMENT: "Consultando procedimentos na base de conhecimento...", + GraphStep.KNOWLEDGE_BASE_ENRICHMENT_UNAVAILABLE: "Base de conhecimento consultada - indisponível no momento", + + # --- Analysis --- + GraphStep.CANCELING_ANALYSIS: "Realizando análise de cancelamento...", + GraphStep.CANCELING_ANALYSIS_FAILED: "Falha na análise de cancelamento do chamado. Consultar detalhes.", + GraphStep.TIM_COMPLAINT_ANALYSIS: "Realizando análise de reencaminhamento...", + GraphStep.TIM_COMPLAINT_ANALYSIS_FAILED: "Falha na análise de reencaminhamento. Consultar detalhes.", + GraphStep.RECLASSIFICATION_ANALYSIS: "Realizando análise de reclassificação do chamado...", + GraphStep.RECLASSIFICATION_ANALYSIS_FAILED: "Falha na análise de reclassificação. Consultar detalhes.", + # GraphStep.FORWARDING_ANALYSIS: "Realizando análise de reencaminhamento do chamado.", + # GraphStep.FORWARDING_ANALYSIS_FAILED: "Falha na análise de reencaminhamento. Consultar detalhes.", + GraphStep.TREATMENT_DECISION: "Agente decidindo responsável por tratamento do chamado...", + GraphStep.TREATMENT_DECISION_FAILED: "Falha na decisão de tratamento do chamado. Consultar detalhes.", + + # --- Siebel --- + GraphStep.SIEBEL_SR_OPENING: "Abrindo chamado no Siebel...", + GraphStep.SIEBEL_SR_OPENED: "Chamado aberto no Siebel", + GraphStep.SIEBEL_SR_OPENING_FAILED: "Falha ao abrir chamado no Siebel. Consultar detalhes.", +} diff --git a/src/agent/state/step_notes_emulator.py b/src/agent/state/step_notes_emulator.py new file mode 100644 index 0000000..5d71945 --- /dev/null +++ b/src/agent/state/step_notes_emulator.py @@ -0,0 +1,38 @@ +"""Human-readable PT-BR messages per `EmulatorGraphStep` for CMS `processing.note`. + +Steps not declared here are silent. Values are kept in PT-BR because the +CMS surfaces them directly to the operator. +""" + +from src.agent.state.steps_emulator import EmulatorGraphStep + + +STEP_NOTES_EMULATOR: dict[EmulatorGraphStep, str] = { + EmulatorGraphStep.RESPONSE_EMULATION_START: "Emulação de resposta iniciada", + EmulatorGraphStep.RESPONSE_EMULATION_FAILED: "Falha ao iniciar emulação de resposta", + + EmulatorGraphStep.FETCH_CASE: "Recuperando chamado original...", + EmulatorGraphStep.FETCH_CASE_FAILED: "Falha ao recuperar o chamado original", + + EmulatorGraphStep.VALIDATE_ACTIONS: "Validando variáveis obrigatórias das ações...", + EmulatorGraphStep.VALIDATE_ACTIONS_FAILED: "Variáveis obrigatórias não preenchidas", + + EmulatorGraphStep.RETRIEVE_TEMPLATES: "Buscando templates de resposta relevantes...", + EmulatorGraphStep.RETRIEVE_TEMPLATES_FAILED: "Falha ao buscar templates", + EmulatorGraphStep.RETRIEVE_HISTORY: "Buscando históricos de respostas similares...", + EmulatorGraphStep.RETRIEVE_HISTORY_FAILED: "Falha ao buscar histórico", + + EmulatorGraphStep.GENERATE_RESPONSE: "Gerando resposta ao cliente...", + EmulatorGraphStep.GENERATE_RESPONSE_FAILED: "Falha ao gerar resposta", + EmulatorGraphStep.VALIDATE_RESPONSE: "Validando resposta gerada...", + EmulatorGraphStep.VALIDATE_RESPONSE_FAILED: "Resposta gerada falhou na validação", + + EmulatorGraphStep.APPROVE_DRAFT: "Registrando aprovação do operador...", + EmulatorGraphStep.DRAFT_APPROVED: "Rascunho da resposta aprovado pelo operador", + EmulatorGraphStep.APPROVE_DRAFT_FAILED: "Falha ao registrar aprovação", + + EmulatorGraphStep.CLOSE_CASE: "Fechando chamado...", + EmulatorGraphStep.CASE_CLOSED: "Chamado fechado com sucesso", + EmulatorGraphStep.CLOSE_CASE_FAILED: "Falha ao fechar o chamado", + EmulatorGraphStep.SIEBEL_SR_CLOSING_FAILED: "Falha ao fechar chamado no Siebel", +} diff --git a/src/agent/state/steps.py b/src/agent/state/steps.py new file mode 100644 index 0000000..6a7e18c --- /dev/null +++ b/src/agent/state/steps.py @@ -0,0 +1,60 @@ +""" +Centralized vocabulary of `current_step` values used across the agent graph. + +Naming rules: +- `<step>_enrichment` / `<step>_enrichment_failed` for enrichment nodes. +- `<step>_analysis` / `<step>_analysis_failed` for analysis nodes. +- Other nodes (validation, bypass, cache, fetch, siebel_*) keep the literal + node name; only `_failed` variants are added when meaningful. + +`StrEnum` keeps members usable as plain strings — comparisons against literal +strings (e.g., in graph routers) and JSON serialization keep working. +""" + +from enum import StrEnum + + +class GraphStep(StrEnum): + # --- Bootstrap / orchestration --- + INITIALIZED = "initialized" + FETCHING_TICKET = "fetching_ticket" + FETCHING_TICKET_FAILED = "fetching_ticket_failed" + VALIDATION = "validation" + VALIDATION_PASSED = "validation_passed" # transition consumed by validation_node.should_continue + VALIDATION_FAILED = "validation_failed" + BYPASS_RULES = "bypass_rules" + CACHE_CHECK = "cache_check" + CACHE_FOUND = "cache_found" + + # --- Enrichment --- + IMDB_ENRICHMENT = "imdb_enrichment" + IMDB_ENRICHMENT_COMPLETED = "imdb_enrichment_completed" # transition consumed by imdb_enrichment_node.should_continue + IMDB_ENRICHMENT_FAILED = "imdb_enrichment_failed" + IDENTITY_VERIFICATION = "identity_verification" + SPEECH_ENRICHMENT = "speech_enrichment" + SPEECH_ENRICHMENT_UNAVAILABLE = "speech_enrichment_unavailable" + KNOWLEDGE_BASE_ENRICHMENT = "knowledge_base_enrichment" + KNOWLEDGE_BASE_ENRICHMENT_UNAVAILABLE = "knowledge_base_enrichment_unavailable" + + # --- Analysis --- + PROCEED_GRAPH = "proceed_graph" + CANCELING_ANALYSIS = "canceling_analysis" + CANCELING_ANALYSIS_CANCEL_TICKET = "canceling_analysis_cancel_ticket" + CANCELING_ANALYSIS_FAILED = "canceling_analysis_failed" + TIM_COMPLAINT_ANALYSIS = "tim_complaint_analysis" + TIM_COMPLAINT_ANALYSIS_FAILED = "tim_complaint_analysis_failed" + RECLASSIFICATION_ANALYSIS = "reclassification_analysis" + RECLASSIFICATION_ANALYSIS_COMPLETED = "reclassification_analysis_completed" + RECLASSIFICATION_ANALYSIS_FAILED = "reclassification_analysis_failed" + FORWARDING_ANALYSIS = "forwarding_analysis" + FORWARDING_ANALYSIS_COMPLETED = "forwarding_analysis_completed" + FORWARDING_ANALYSIS_FAILED = "forwarding_analysis_failed" + TREATMENT_DECISION = "treatment_decision" + TREATMENT_DECISION_FAILED = "treatment_decision_failed" + + # --- Siebel classification & SR opening --- + TICKET_CLASSIFIED = "ticket_classified" + SIEBEL_SR_OPENING = "siebel_sr_opening" + SIEBEL_SR_OPENED = "siebel_sr_opened" + SIEBEL_SR_OPENING_FAILED = "siebel_sr_opening_failed" + diff --git a/src/agent/state/steps_emulator.py b/src/agent/state/steps_emulator.py new file mode 100644 index 0000000..7754bee --- /dev/null +++ b/src/agent/state/steps_emulator.py @@ -0,0 +1,53 @@ +"""`current_step` vocabulary for the Response Emulator graph (kept separate from checklist's `steps.py`).""" + +from enum import StrEnum + + +class EmulatorGraphStep(StrEnum): + # Entry / orchestration + RESPONSE_EMULATION_START = "response_emulation_start" + RESPONSE_EMULATION_FAILED = "response_emulation_failed" + + # Case fetch + FETCH_CASE = "fetch_case" + FETCH_CASE_FAILED = "fetch_case_failed" + + # Required-field validation + VALIDATE_ACTIONS = "validate_actions" + VALIDATE_ACTIONS_FAILED = "validate_actions_failed" + + # Router + ROUTER_DECISION = "router_decision" + + # Retrieves + RETRIEVE_TEMPLATES = "retrieve_templates" + RETRIEVE_TEMPLATES_FAILED = "retrieve_templates_failed" + RETRIEVE_HISTORY = "retrieve_history" + RETRIEVE_HISTORY_FAILED = "retrieve_history_failed" + + # Generate + validate + GENERATE_RESPONSE = "generate_response" + GENERATE_RESPONSE_FAILED = "generate_response_failed" + VALIDATE_RESPONSE = "validate_response" + VALIDATE_RESPONSE_PASSED = "validate_response_passed" + VALIDATE_RESPONSE_FAILED = "validate_response_failed" + + # Draft persistence (generate flow, no close-out) + PERSIST_DRAFT = "persist_draft" + DRAFT_PERSISTED = "draft_persisted" + PERSIST_DRAFT_FAILED = "persist_draft_failed" + + # Draft approval (no OCI/Siebel side effects) + APPROVE_DRAFT = "approve_draft" + DRAFT_APPROVED = "draft_approved" + APPROVE_DRAFT_FAILED = "approve_draft_failed" + + # Case close-out + CLOSE_CASE = "close_case" + CASE_CLOSED = "case_closed" + CLOSE_CASE_FAILED = "close_case_failed" + + # Siebel SR close-out (runs inside close_case) + SIEBEL_SR_CLOSING = "siebel_sr_closing" + SIEBEL_SR_CLOSED = "siebel_sr_closed" + SIEBEL_SR_CLOSING_FAILED = "siebel_sr_closing_failed" diff --git a/src/agent/state/transitions_emulator.py b/src/agent/state/transitions_emulator.py new file mode 100644 index 0000000..d62aab1 --- /dev/null +++ b/src/agent/state/transitions_emulator.py @@ -0,0 +1,49 @@ +"""Helpers to append entries to `processing.transitions`. + +The transitions array is the chronological audit log surfaced by +`GET /case/{tx}/response-emulator`. Each emulator action that mutates +`processing.status` (persist_draft, approve_draft, close_case) +calls `append_transition` with the current case document and gets back +the new array to persist alongside the status update. + +We do read-append-set rather than `$push` because `AutonomousManager.save_data` +only exposes `$set`. Emulator traffic is operator-driven (one request at a +time per case), so the race window is acceptable. +""" + +from datetime import datetime, timezone +from typing import Literal, Optional + +TransitionEvent = Literal["generated", "regenerated", "approved", "closed"] + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def regenerate_attempt(transitions: list[dict]) -> int: + """1-based index for the next regenerate (1 for the first, 2 for the second, …).""" + return sum(1 for t in (transitions or []) if t.get("event") == "regenerated") + 1 + + +def append_transition( + existing: Optional[list[dict]], + *, + event: TransitionEvent, + from_status: Optional[str], + to_status: str, + attempt: Optional[int] = None, + actor: Optional[str] = None, +) -> list[dict]: + """Returns `existing + [<new transition>]` without mutating `existing`.""" + entry: dict = { + "event": event, + "at": _iso_now(), + "from_status": from_status, + "to_status": to_status, + } + if attempt is not None: + entry["attempt"] = attempt + if actor is not None: + entry["actor"] = actor + return (existing or []) + [entry] diff --git a/src/api/.DS_Store b/src/api/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..1897aa0dc8709415a3c241138d7f96abd7198784 GIT binary patch literal 6148 zcmeHKF;2rU6#XtmR4qdXkQj3Wx_1aw*w`u|F%{ZWEmG1%lnSx=0^A8E&cX$ldEd4~ z+{8sJ2vz@A_Dg=A?bnyuu8ByEHtB$<PedIQ#&#D~L-2DhwP?A_2~e3nW^_k0TGE^* zHMS(g0de5JIlyzbhuJ!zlAbYlzvu5HTMWjtA}^if&oDDDl4P0{Q!ubl=ydq;ezA+4 z{(@aDt15V%qGL+2!jvd0bG3r*d)%#k#iv)hxV~n(ahijD=S5ZZ&wO9^IqU9H4xK4J z8Pq<(>j#?qnuom*FQB)v&w$oOuCG(G&Ldj8RXm4A-&J+Js51iEw)zRE+G2TnTIPKp z(uN-4T(+f!Uf!Uk@-(2sV>G_S3d0+pUNOMl2KH=@4*%vP!~t<Y9H=_L>qCUX7(3J! z&C-F&907oBq)p)3tl7Yb6TsM^wulTu*``3-RJkLDvdxi?94~gLE!yU!%*g1+jjY@W zMVZl&j|`nuY*A`)KpgNLIIy>U-v2Md=YPME+=&C?z+O3^+Q~Q>;VZelwf5zBuZ>V{ rQ8<{VEt)2%+;(g(cq=|bF@a|hUjSo=+9EOt`6FO#kV+i*QwP2P3-`1` literal 0 HcmV?d00001 diff --git a/src/api/LEGACY_ROUTES_DISABLED.md b/src/api/LEGACY_ROUTES_DISABLED.md new file mode 100644 index 0000000..eab1a6c --- /dev/null +++ b/src/api/LEGACY_ROUTES_DISABLED.md @@ -0,0 +1,5 @@ +# Disabled legacy REST/executor layer + +The original route and executor modules were moved to `legacy_reference_disabled/original_develop/` and are kept only for audit/reference. + +Active endpoints are implemented in `app/main.py` as thin adapters that call `BackofficeNativeRuntime.execute_workflow(...)`. diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..88652b0 --- /dev/null +++ b/src/api/__init__.py @@ -0,0 +1 @@ +"""API Layer - FastAPI application and routes.""" diff --git a/src/api/__pycache__/__init__.cpython-313.pyc b/src/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed19873671e21d22e66a27a8a30061f294e0769a GIT binary patch literal 246 zcmYL^L25!V5QdXltO(vAbSI?-T}#1DL8v0Q>|&TEc@p(y9+OzH_vj&drMK+`3hx3< zpwfTy{~ru9|8%3#P-M*(_xoA)?*doJzt9bG_!L{gVkgHp9}kPoQoZ2Cv3gX`7*k<v znxi+EJOzcpDyCC%ak3uE_71?30r(PK+1S47IwWoNYP(b$roA&MYTUVypkl1unfL%? zynaJ-psMo5fuZp3IC)F|Gdi--*mw=#gHHgH=F$%4HQCeAJ#jzZDlf!R|EDLVY{iss GGyDP~JVuEC literal 0 HcmV?d00001 diff --git a/src/api/dependencies/__init__.py b/src/api/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/dependencies/__pycache__/__init__.cpython-313.pyc b/src/api/dependencies/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..268b39195859aa714c783de2868f00eaf35d2d9a GIT binary patch literal 147 zcmey&%ge<81dmTCW`SuC0RxOs#%C5FV=6;BgC?WjN`@jPAn!9s>XwmyZeEFgN@7W( zep+Tld~!jWesNK<equqUeoAUVYF<ieUUFt?v3`7fW?p7Ve7s&k<t+}I-29Z%oK(9a VR-h3eD~dskkIamWj77{q763`{BDw$o literal 0 HcmV?d00001 diff --git a/src/api/dependencies/__pycache__/logging_context.cpython-313.pyc b/src/api/dependencies/__pycache__/logging_context.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5beeb8b2dad0ff6b895a5a20dff21e61b14e1171 GIT binary patch literal 3221 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 literal 0 HcmV?d00001 diff --git a/src/api/dependencies/logging_context.py b/src/api/dependencies/logging_context.py new file mode 100644 index 0000000..86290c7 --- /dev/null +++ b/src/api/dependencies/logging_context.py @@ -0,0 +1,58 @@ +import uuid +from datetime import datetime, timezone + +from src.api.schemas.anatel_schemas import TicketRequestEvent +from src.core.config import settings +from src.core.logging import ( + set_conversation_start_ts, + set_request_id, + set_requesting_agent, + set_session_id, + set_source_channel, + set_trace_id, + set_user_id, +) + + +def set_ticket_log_context(event: TicketRequestEvent, transaction_id: str = None) -> None: + """ + Populates logging contextvars from a TicketRequestEvent. + + Centralised so both the FastAPI dependency and the OCI streaming + worker call the same logic. Covers correlation IDs (request/session/ + trace/user) plus the conversation-context fields required by the + OCI Logging policy (source_channel, conversation_start_time_ts, + requesting_agent). + """ + transaction_id = transaction_id or event.transactionId + origin = event.origin + submitted_by = origin.submittedBy if origin else None + + set_request_id(str(uuid.uuid4())) + set_session_id(transaction_id) + set_trace_id(transaction_id) + set_user_id(submitted_by.userId if submitted_by else "") + set_requesting_agent(settings.AGENT_TYPE) + + if origin and origin.sourceSystem: + set_source_channel(origin.sourceSystem) + set_conversation_start_ts(datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")) + + +def inject_log_context(event: TicketRequestEvent) -> TicketRequestEvent: + """FastAPI dependency: calls set_ticket_log_context before entering the route handler.""" + set_ticket_log_context(event) + return event + + +def set_emulator_log_context(transaction_id: str, user_id: str = "cms") -> None: + """Populates logging contextvars for emulator REST endpoints.""" + set_request_id(str(uuid.uuid4())) + set_session_id(transaction_id) + set_trace_id(transaction_id) + set_user_id(user_id) + set_requesting_agent(settings.AGENT_TYPE) + set_source_channel("cms") + set_conversation_start_ts( + datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + ) diff --git a/src/api/middleware/__init__.py b/src/api/middleware/__init__.py new file mode 100644 index 0000000..6050be0 --- /dev/null +++ b/src/api/middleware/__init__.py @@ -0,0 +1,11 @@ +""" +API middleware components. +""" + +from src.api.middleware.logging import LoggingMiddleware +from src.api.middleware.error_handler import ErrorHandlerMiddleware + +__all__ = [ + "LoggingMiddleware", + "ErrorHandlerMiddleware", +] diff --git a/src/api/middleware/__pycache__/__init__.cpython-313.pyc b/src/api/middleware/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..05c5bbea1240a653dfa8669cd0796dcc639a4948 GIT binary patch literal 388 zcmey&%ge<81cy&5W<3Yek3k$5V1hC}YXBKj8G;##7=jstnY<ajn2MMbKx}4jW-pc^ z79g7?m{p&th&7!}lf6oY%Q3)HAvZH6B`38!u_#p`IX|}`KQA?}q*#yZCCE@s##@3u z`RVDIdFj4zWk5kO*P^2QB9Fv8pw1$MOc66sPZ0}{@Y7_w#U3A@lAjzOe@m*kC|NJD zAX5)vm0k|SRIrX)3Ro1Rf^3M-fLH-G6J%gwPELIMN`}uM$K5i}&&?~*Pf09E)KAN- zh)*s^(+3))541@iVUvD*d}dx|NqoFsLFFwDo80`A(wtPgA|9Xtj6hth2qZo*Gcq!M kWMX1u`O3k-XnB{x`2!mRtHcc!nGdYY+)Rz^MWDb30MH(82mk;8 literal 0 HcmV?d00001 diff --git a/src/api/middleware/__pycache__/error_handler.cpython-313.pyc b/src/api/middleware/__pycache__/error_handler.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2a7f282532081a5f812d0cf4c3a0f2fd35d57d2f GIT binary patch literal 5565 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 U@rq;9F>2$)<&OAg1PP}5e<w!>r~m)} literal 0 HcmV?d00001 diff --git a/src/api/middleware/__pycache__/logging.cpython-313.pyc b/src/api/middleware/__pycache__/logging.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..309e36105f071a0ca18b92f443f79ab5e77033cf GIT binary patch literal 2800 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 literal 0 HcmV?d00001 diff --git a/src/api/middleware/error_handler.py b/src/api/middleware/error_handler.py new file mode 100644 index 0000000..1de7be5 --- /dev/null +++ b/src/api/middleware/error_handler.py @@ -0,0 +1,151 @@ +""" +Error handling middleware for FastAPI. + +This middleware provides centralized error handling for all exceptions, +ensuring consistent error responses across the API. +""" + +from typing import Callable +from fastapi import Request, status +from fastapi.responses import JSONResponse +from pydantic import ValidationError +from starlette.middleware.base import BaseHTTPMiddleware +from src.core.exceptions import AgentException +from src.core.logging import get_logger, get_request_id + +logger = get_logger(__name__) + + +class ErrorHandlerMiddleware(BaseHTTPMiddleware): + """ + Middleware to handle exceptions and return structured error responses. + + Handles: + - ValidationError (422): Invalid request payload + - AgentException and subclasses (custom status): Business logic errors + - Exception (500): Unexpected errors + """ + + async def dispatch(self, request: Request, call_next: Callable) -> JSONResponse: + """ + Process request and handle any exceptions. + + Args: + request: Incoming HTTP request + call_next: Next middleware/handler in chain + + Returns: + HTTP response or error response + """ + try: + response = await call_next(request) + return response + + except ValidationError as exc: + return await self._handle_validation_error(request, exc) + + except AgentException as exc: + return await self._handle_agent_exception(request, exc) + + except Exception as exc: + return await self._handle_unexpected_error(request, exc) + + async def _handle_validation_error( + self, + request: Request, + exc: ValidationError + ) -> JSONResponse: + logger.warning( + "Validation error", + extra={ + "path": request.url.path, + "errors": exc.errors(), + } + ) + + error_messages = [] + for err in exc.errors(): + loc = " -> ".join([str(l) for l in err.get("loc", [])]) + msg = err.get("msg", "Invalid field") + error_messages.append({ + "code": "FIELD_ERROR", + "text": f"{loc}: {msg}" + }) + + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "title": "validation error", + "status": 400, + "correlation_id": get_request_id(), + "detail": { + "messages": error_messages + } + } + ) + + async def _handle_agent_exception( + self, + request: Request, + exc: AgentException + ) -> JSONResponse: + error_type = type(exc).__name__ + + if exc.status_code >= 500: + logger.error( + f"Agent error: {error_type}", + extra={ + "path": request.url.path, + "error": exc.message, + "error_type": error_type, + }, + exc_info=True + ) + else: + logger.warning( + f"Agent error: {error_type}", + extra={ + "path": request.url.path, + "error": exc.message, + "error_type": error_type, + } + ) + + return JSONResponse( + status_code=exc.status_code, + content={ + "title": "execution error" if exc.status_code < 500 else "system error", + "status": exc.status_code, + "correlation_id": get_request_id(), + "detail": { + "messages": [{"code": error_type.upper(), "text": exc.message}] + } + } + ) + + async def _handle_unexpected_error( + self, + request: Request, + exc: Exception + ) -> JSONResponse: + logger.exception( + "Unexpected error", + extra={ + "path": request.url.path, + "error": str(exc), + "error_type": type(exc).__name__, + }, + exc_info=True + ) + + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "title": "system error", + "status": 500, + "correlation_id": get_request_id(), + "detail": { + "messages": [{"code": "INTERNAL_SERVER_ERROR", "text": "An unexpected error occurred"}] + } + } + ) diff --git a/src/api/middleware/logging.py b/src/api/middleware/logging.py new file mode 100644 index 0000000..be46f76 --- /dev/null +++ b/src/api/middleware/logging.py @@ -0,0 +1,56 @@ +""" +Logging middleware for FastAPI. + +Wraps each request in a ``log_operation("http_request")`` so the +started/success/failed pattern from the OCI Logging policy is enforced +uniformly, and clears all contextvars on the way out to avoid leaking +correlation IDs between requests. +""" + +import uuid +from typing import Callable +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware + +from src.core.logging import ( + clear_context, + get_logger, + log_operation, + set_request_id, +) + +logger = get_logger(__name__) + + +class LoggingMiddleware(BaseHTTPMiddleware): + """ + Generates a request_id, populates the logging contextvar, and emits + start/success/failed logs via ``log_operation``. Context is cleared on + exit so subsequent requests start from a clean slate. + """ + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + request_id = str(uuid.uuid4()) + request.state.request_id = request_id + set_request_id(request_id) + + method = request.method + path = request.url.path + client_host = request.client.host if request.client else None + + try: + async with log_operation( + "http_request", + component="api", + logger=logger, + method=method, + path=path, + client_host=client_host, + user_agent=request.headers.get("user-agent"), + ) as op: + response = await call_next(request) + op.add_field("status_code", response.status_code) + response.headers["X-Request-ID"] = request_id + return response + finally: + clear_context() diff --git a/src/api/schemas/__init__.py b/src/api/schemas/__init__.py new file mode 100644 index 0000000..582b38a --- /dev/null +++ b/src/api/schemas/__init__.py @@ -0,0 +1,12 @@ +""" +API schemas for request and response validation. +""" + +from src.api.schemas.request import AgentRequest +from src.api.schemas.response import AgentResponse, HealthResponse + +__all__ = [ + "AgentRequest", + "AgentResponse", + "HealthResponse", +] diff --git a/src/api/schemas/__pycache__/__init__.cpython-313.pyc b/src/api/schemas/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..693312f6705659f9bc14b2c521e13ee6236d0468 GIT binary patch literal 460 zcmZWjy-ve05VrF}tCq3?o5dCh#iBDpDidu5D$N5}#&KG=NnC8V2sR#qhv1dGFfg$J z6&nK%4eG#2K7XgX?`PfVw1BH_?`Zbz{?}^wE3XFYV+Wo<0u)GK;tk=zQy!|YKlBHI z3I?GH5x^h`uA$nQgwf7Jx78c<k!Dj~P>nLFkm9#Xt_`AP>abqOQggJTxk#xIvW#1A zm0bkwZg0X%Gv1s>p6%50hGO?FIL*y;TuVg0@*PhFRSR9aiW;^-NGcg2c3&$N(}jrZ zBypYMmtzn9Z#O1ewMvTSIU&zr?Zkm)xF`)y@nzCSBPC~?8I5U`m}H6;{7$Mlkxbwl z%H}f51mlFsa>bPqsodUR=Z#%5TqnbXh*B6r;>Epfo)$7)=KM@`oF{ZoAG-GLLkK@S W4+dXN08d`P`DYNcy~l=XyX_AS2ZiMT literal 0 HcmV?d00001 diff --git a/src/api/schemas/__pycache__/abrt_schemas.cpython-313.pyc b/src/api/schemas/__pycache__/abrt_schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7f49b8c27a483a1b27225a32ba2b58807d524f65 GIT binary patch literal 1518 zcmah}-)q}e6qaQ9x1Fp>I;U&0-DVA`pv2o~VTESUWpfgHaYN-LW_XBe>Dp5(>#Fxk z8TM1&*1Qbwp^(Syv46+@0UJXhg0Y7_<zX;L_P%pvC(T%40)4vo`;P8CN9Vgb%jYu^ zT%SE!ditj#NpHmD;)+iOXWzi!rKC%`T#?E$lgkQIkeDl#SXpH%lIEo)NsoOX>1sp1 z9~-~N;t?AMHgSbbw3AxuRD@_snEn!TvP~`Ggz=J197~hK)ML-Lsf!(Ryv`Ueli7#) zOQ|6-S(lii%Ph7mHB{JM(PO&$Kw<GlqLI|&D-bu~6A_<^_~a#@($f*2Ze$=nbA``F zd^X~9g4gpb*Pha*!|bCb^PiCC0pb3sNNKgVvL0}sb_iSHh*Q(X4&uZNnC*9w8+4k4 zp*!n4CGC|QW-Y=^W=G|P*>SK=%qs0&yRyi(zNLRv4HF%1bIYyu3R|vao7g8Dd5n12 zwh-*kx?!^A;Pwt9z4<E<l>7e)q5PFp)$(D=FkIXrh7o2AqeHF05#yX;JP)ulQKk)} zWi#$Owo6=U7)*sKSr#CSai4{tW7nlV7Cqvi=Y*aYy4s}FVN+sw-7vTaF%9hd%x(sd zJ;Y9fb_TZ;wu>FtFIq(?7VR+ll$bs*;x=)8qs1V{A1Qlgz}GA`vH6U)TDD0Hle+ta z`L;#h#uPa-i`cV^-28!bFsuO$GA5H%ugj9KTLhw8ybSP@bW&J2(g&-1!$SR_(w`lv z^^=*xQEZ?+9L}sAlwPcl)U}hj8%MVXcfTIal@2yu)JAG)!jy({8-jVQZm{`_#ouk! zpF#eAtBS-LsdpGQEetzR-`ZBuQ;|J0pk^m(`qI3Lo{7}lm3Om|g>x+5R=@<gv4yz@ z?IWk6d)3~I4$TTUvM_>=%mc^AEDcJS5M2y-Z}zcA7`7<G$f5|FgmW4uP1^CW+YLU1 z(6I%<`shMOa7KRn6@d%(X_&w!JV|e%W_uJ_1bGg2DZ2B?nn(Fv^ywN{TMILSt#v)J zWkp`mrr3M1HoGQZM!>9q$dZd<B<g2#0<H@XiLj3Zye|L=C<qY78+Yr57~Q-8Q9l0; z08FuP>-hHI-sW&&3oNkN*AKTx>ek8P((%2){qk_J0!^>?&BO0UYGuNHJ6zn2Vrz%h zk-B?=mXEE$=et9+cTn!9j%I(!zgG9g+ln2lS~5&|T?@N#FqrsDMwNsKzw6m<dt6N9 z*C@Y#=NzUUj!*0t>|=OShMSAN{uPsD`44INcj@MNLX|h=zIHB&=lPT>*X90$zhPFF Hqb&XbBzueb literal 0 HcmV?d00001 diff --git a/src/api/schemas/__pycache__/anatel_response_emulator_schemas.cpython-313.pyc b/src/api/schemas/__pycache__/anatel_response_emulator_schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a757953932ccc5309238e1b465258ac34bf23dab GIT binary patch literal 7730 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 literal 0 HcmV?d00001 diff --git a/src/api/schemas/__pycache__/anatel_schemas.cpython-313.pyc b/src/api/schemas/__pycache__/anatel_schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ca0645a033fef558fb131662c615e8e8d306a6e0 GIT binary patch literal 15442 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 CQJfzD literal 0 HcmV?d00001 diff --git a/src/api/schemas/__pycache__/emulator_rag_schemas.cpython-313.pyc b/src/api/schemas/__pycache__/emulator_rag_schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cf8d5864a9cb53fd2e8de3e9f4842e8235e8c083 GIT binary patch literal 1354 zcmZvc&u`pB6vxN*_}6YWNl6p+mx=>I#X`+q+5-q70jip&kfN9a2TLPk&u&b;w(~qI z5l)p>DsbaciDRz(E1W=)&@xxlLvBdB+<7y0q(<76_RV+R^LsP%d1EgIgC4=(KfmvO z_pXVfnq2;zPAoDMuSi6IL`GyjH4aQL1EsB})&T_?kQ?M3646^EvST|oAJR4x9PM|| z@5c0@)p=d*_0a3T;q}J7u>Zq@uTHb16nPpn=`o)qO)#n;5XU^=lWCC%oE&}nI1s!M z0WY(<%1g0#p$dnFq>sw!1V{g~q~Tvy886ay$<`X(WN`<@D-shh@Jwc8fEAfQBMa<^ zf-|;5M{YmrmODzuQ8k5>tJ)VHW=dc1NsW8qjoe5|918}#sjEgLzmdbxl^$bdGU1HL z9%GX#n-)s1F?KRdiY~Ij*eHia6nV+ZiZM{7!B_RS8N*V@fF(je{_PZ3!Ut5N7WgOf z!{MY9!z>ZWaFl<~(t0#(ARQ)kKJ2IsFKIDI#;kMh)u*6(s*PH%Swr)Z%r|epcrxF) zH+u$~m*Femy2g>kK8CM?dym94wj=W%MzpfDa?nSWifnLWFS75u7`G!w`&{r5vL|o9 z37IF59v|^$T8JkI_+o(B(#$jIrd26;DWsR>$fZpAq4XwPBnp70684~vPlGxRs61F# z$yOskBIl$mE5)-a5u>z6MU@D+p`M^wo(kAd8H#Ru{A)^WT^d!cc^l1-WbXB6kLQEU z*%$Nmt=Y5ro#4g(PuVYD|NQ(f8nn48Z8f@YFtQ4rjsDo_0J^AJfF3FhF!oW|8mfwF zfZFG3lpgfPe%O;YSD~!&#Qfqqt{`2!p-8ro1A#2`h1FB1(U)7=CsPim(iT<Co+G)F zLK8U*`zz7X0$b5Sx~0ln_!_o%>n2WD61}Se%BcM`AsQq)xc0|e_x~L1ozuPf*3LQI znXm1h)7^Q0eRj|qf9>Ud%U0zchQ4$e%c>MRNjbifpf0EM>h7}v>eB;y6Z)#_KI)~f zHduNew=d==@BwD1Pebzn&A*mm7=M$GUX$(D<nGcjjrV{3{OtOYpjh^(v3nLS2@cD3 O#~7UX{}CMY_WlP1vqZoE literal 0 HcmV?d00001 diff --git a/src/api/schemas/__pycache__/imdb_schemas.cpython-313.pyc b/src/api/schemas/__pycache__/imdb_schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ecc1c13ec4f708ab141f897552a3d686448642a7 GIT binary patch literal 1427 zcmah}%Wm676dgW9QLngps5K0yWz@>Hf*=A~cM+gXkw_6Lxsr`it3tqlCPy+8id1H( zwh~Z4ySh7h5kNpUUM7Fg&yZlCh-Z^USKhU-({<-ka-sqOLV#!P+%x2y%X^0BnM@Li z>+=V>$G^)6{RNZDE4B#En<99IOk_$ss3uXVCR5pfxxAy)RI1iuG*;87W}sD+L#DEZ zOtmF_sm!0J@hBS;S#2q+cj5*843-pRo_J_6QV$&B@mR$ro>Ne`T6XP_D-Zku*LQoN z8~B!I&tEymH~PL4^Jl1qsAM84n-W#>sHF-evZ<KrJ(<Q@S}P9ATe=zBgb`THj7Myu z6&HCO@@9f2J8~h(Q`N4s|2=uyCoDWu1=AR=l=>_Tx`Y~avuc(bFC?CF2(zghiQ`(A zxy<qJ4znfCC4OkQjzG8V+7^iC2|G$-*Li^oDXwGeTU~-NPh#8+oW2Kf8sn#Z%Uduj z7`I)@LeKSyA7D&D3C#e+1#pFhl#4}{?+0PD3lod30F}j<LCVHf7*cn?9}?hJ1a~fO z#5={V9~PaWD8|^M!DC{FtY~$FWZb4g*iQj{f&<$v?pyYgpe@QE*be+32@PE*c)O;^ zs9m&rZjspsq-(LFE500FOz!puG$D420K~J~0)9oO>$i?Sn{Gdvtye~s!*9ptZ}k(k za++P6d@;RKnq|wQ>LL46Ez^%KAAWb|CxU)|=UaL!u`NUYBM&5(JRl1ffJ0KkA^L)i zzj26c>Jgh>ViQYjGGa4JY$|e5nqCnu%JB5uMXVS2jGXC0z><wNnbGqspQc2bs}K%T zrf6B88O=eD@FeuF&mbONTM)~ByE^B~Z!I`1m-Z^fQe&@XIDrwmVc)Z;;aG<7&w*u# z?zIEjB?cv7Kz&QB=@HK|;OrUAYTekX*2@nIJjF!YEzcx&BZv%BSfL*Xk@PCSHGt~? zkr~$^iUYvcqc;FFfGogGfR6z_0r(IAEI9A$Dnx6S0H22)2oUzj-+sP5{kk#B?~clc zTjTACGBGFm(fwI6ccSi|8u?@AkIc-djA~<ja{VX+=GnaSygB{GoNYFzYIgkdv2ygw ziP}89weeC<zr69oKQSezjdsSXlTVLs{)?2DHWvZ=Hwp=^_Xdt7&Z|w~zZDh7wQ$gL z{m#6!sH&*a$kCDR|JTCxujU8&KFx`DNaAK&0{&JcNqU9yuh7N}ZCt3b^hg>PE)a|t PdR)@R_g*70M1uYU<mhQh literal 0 HcmV?d00001 diff --git a/src/api/schemas/__pycache__/portability_schemas.cpython-313.pyc b/src/api/schemas/__pycache__/portability_schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f72ad108f0bbc05ef26378b6165df203d9507b7a GIT binary patch literal 3117 zcmb_e&2JM&6yLQsiIdoIJ_#RzZeo(ye8fNjK?}4rAq}Af#3Jz_qh-B24h!~<ncamZ zDk!(Y%{d^sw@5uSNWF3EsZ#y{uOcCNrK*RXxK$#(aO!)rCfPus9BRpLci(T`o3}IX z_uF}$NVEy?J92jD?r=;H{=$d1zoy2`>pa{%6BI!aF9_2j6Q?6ABI9%9LUdYUk}PZ& zh6E+LQ&6Owcq|&ku_o?q0&jE3+swT!;Ejd6EwinewpSRKiTJJGXg0Y>4N~-*CQVW_ zGNK>5RCY{iX+=HA9@zB~C*k>-kQ11w2rQzAEIKUYBq%DPL>1{|gf-=wb1h2KD2!|P zo4G&6{VnKMVyqR*@VhTl=4g4dXgYIO$bFaC&MTa0>`m9WYdf?=*r+XERL(0CGtWi8 z-5|EkOkSVgT(V8uu-=S@M%q+2wW4ej-DQwcw%k&lFgZPbc{1a-2dF~y8Twq9f$3wa zYH1~+s(zcQmZ;$t@jkAq_g$^nK$5CjFq!QXO^aAmRhb0!u{an%YCFsaT(d0dXt-!L z;2j9{sj7`GUDX_inR(YCh$R7g4S&J6vL(yO8d-QZ`7)z-iSF20ZI)P$T3}GqL(1-{ z@HW}Jrr)E5f~gZ#r`7|)9Mhm1W3o2Wvs&5A+WH+*((G(`V@1`5Z>&7WT3}LcaCz+! zFh2_Q?*3}DmO15hpI(@JGQKLEuJ;dAmD<=fumAeOg-Xv$={oCw8@P2lJQhEt9dOcI zY>TF7#inSWyfqpv6XPw@7D8J?4b{fc_7K`0LgO5bhtNa_?ciu4geF61Cr3L%XjcgB z=4dj6_Jq)0j&_F7z7V>Nqg^4iKZI`QXm<$R5khxzv?qk_LR1-Gy|a<bZa*2K(RqiI zUZK>?%<qG?mo=NWy+LJ-%8bmK(6m%Gh`in?hGr0k5xjNhQvuy%i1_eu?0@$Vm}$)- zMGf;ow?EKUep&e$vTK{O%jxsVCHchhLx(edr>5gTWXm<vGU&tkBbUmAX$ECH4N%-M zEh?uO(ao|6gdmys68Z^>F%D(+43Nc5K_%lWFAQ^*%I7X!#qoT=J4yZcde}MYxSxfh zJ`}Mr(KyKD{Me>iDKlqDNlqJ-m%l}w-Rl!hP>V7wc77-D-Jv=yzQ0g@tlYrnQwr#Z zYkzbb{ALsx*LDxXn@ng9UqG7{P2F@pVJi|WkX&LwEOAeVl8^#f-_ESASvqWT3HIXs zk~i&bFtW&Zm**VDoLsaUok8Uih0MGI!+HJAO|ck)Vwve<op51YXu8q#py3*fLd#G) zSTCABG}xDH0L^YRd(hxEGa1bwnjtjUFl;XxtdMO(gYyM)w;k_xzBNb<y90C%6mxK7 z$*G+r-e3XraeFnjxO-J9)CUHa_STNx@CI&zj!spSrx#bHoAr_X%M-OTWpCtuP3o=O zsoIOBRq1{`m0s?xeM!AkdEsK^M0I@e^r}>@4~;HMwJ%g}==Q>Qm2~y<#e=KT?Z()g zm%6nv_EtT8aM`GRUG~zDcw05L)bmpU6YohaP1KHG@%AVSQ%~+x?58lD@`2idA}lXO zHxkoE#gDv^$DxWJH{8S<DXhC+N`+vlaFw?HGW-h2e;*VY+W-D>;eY=!<l;(PNx%Um zk&EX#8uNB=9Zj+%=xC?k6VlNu#4f`JoxIus?=iz#!3HA9TP%T(*nsn+4W2r4Wdeff zxXdDkoS&0Jx#Y8#&--lwSFggj`LVa;-Z;;EG`1^<4Vc7_KQ0zN1!BJPf|)v}(<VXZ z!eOoa1}7*A2fd_03YuGl!v)AMWpk_@!u`a0h+`5vAYA8%3CG|39l~)EW^gt3VMeKl z-<JdU%=xnwv9NN623OW*V1QTwg}(I$rZy-v)u3D68`L-Grq|`+C8Ksi^W^-(bR|~p zS%mh_*GCU5U#)#bz0q<_>Z^Re6kU9T#M*!8_uiWFgSY<?bo$Y1((6gTlpY1mi$%<2 zVxYQnhGxNOjYYx3)x~dy4K=OVKww(X&0mNYzga2#*s0*`c!uE@5rjgpkAe9+DvIKt r!tfu$uC?Zfctfo2SQGHN))p6M#frHm;B{?JllWMyX8sZI$_x7!`RP~T literal 0 HcmV?d00001 diff --git a/src/api/schemas/__pycache__/request.cpython-313.pyc b/src/api/schemas/__pycache__/request.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..97bb5ea466d0063bb871d95ae272c357524b8df1 GIT binary patch literal 2164 zcmah~%Wo4$7@vJ_HcnzE29gk9c%_&Un^#gms8w1@8&fD^O4X{CR-4^%ykWg-X4fS4 z38{w`^*}fvapc(k2mJ$j>WPSpwW`!gJt6hr$gO(qH@oWqs%XdZH;?Zz-~7JE4#vg| z1mo=Tk#$`~=y$#u4kcR*uEOFmB8VX(5b>H&6`5F-m{gUSjQN>#O{uC(t!hlG>P*LI z8XZAIo<&5diHlNdhmEYMC9QkMxJB=_X&B+qZqTL`;(EYv)S%c}qke>!Z?2RL<Fkev z;%4BqJ&GM#cYPY-n;pmUBiH8V)Jyg5S)S`yk?XHv*SCWvU$E4EST<hpn@dtW^1c<) z>s%{VuDaB7;)yz+%uGFCAf$iVirm1ryjZ^C+EFYm`yD%VJ*8T<8T<^3$Eb#w0GNnG zVA63!L?+i1c(p`iq7ap67iFf_w3<%zw?IDOjSz#3UKH6#&8TS{Uf|}2+}bD^Bjdo$ z0e1{E$IpvgCX<^;<R;(-eNlj87_~gf*ECY7Y3F4!Ig8fi#gu`DGb@lOQY3rGUb2r& zlbNzeX3J7VI1N8GN%pg;wUN?XoLlD5XK{Hk3P!qr?1m{pI|))6Ai7EIHb)A=v>Zju zU2R7+ykuY)O&W$EbqU`NDI4x^6yO#MY~bM&JUA|+&MACm;mm0m*lt+NvLnjEQeq<n zIk0ZJ&Luny3C<4j%9X?uJMiy;0t7zrF>D|K+HTZI^!b)<Mz1Jaj^n1GO=N-jdd?Z; z7aUPH4-j|vy}@<J5ChW7^r*iUHDGOR4%ed&4Q=Ko=@V-i1~<C%Bz?*MI9(Fh1l%4Q zFCX5;%m3qYNsK3;F08f}nRP%fV4c*8mOVcf?!_vjQJeX;m}Lc@cro(-U=_$?bX&NU zB@=9S$1r_PWG4=!#8+7vBPjS`0#f0>^^c*F*j@TOtx*a^ZbiFE0rl+%5vI*n)WOgJ zHX1IZQOmL^4t%du?w-7!CKI+TpG&UNHwpt9N(h<QiVe7|O+RD|N?MF1+Y4C{9uaZ~ zFpgI%O+TtQmFr{$-(<l$wWF|-q_tURR+DZ7?2Z}OZe`W7?*#R_Yg04Lwa9gXmok-* z*%hnhR<a&e$$Cz?)rkvP0+`^mnLIB(_-cKX|HVJbJ>=zmAN6&VFFf3Mu<@|-p!3V* z+_M8K&nK(Ta@F2Mo*#eLd8SVfKorG=^$@Bgb-;w2W>&1zO{>}RXxQEVG4;IQ6mA3? z96#I%+PFcz7Qp{rx7dd2TsU*~+(MRe3+ES?VhIjnk@|p}#s#1NW8Jg-wKf1$l2{%t zWd%;Afc$_;#aK5@A5b&R*f7nsTf;hMns?ilmvJUcv+lAm@}P(N0kAdGbOPHnnapi* ztFZ*Zi$O36bW+qI@RPgXql9AFfrgx1%QUW}bHe*#JZgJZ7&d9t2pqPbpX8E$#iLet z*Wx{<8Af2yHm%)O1*VU20|z*n<%E9&6T(|x_!E$C(2l4n`c57dXL|Z*Z>rdvn(h@B zc1Dz<djF$u$p9&ex|2hP=X-jtSG>HVNQc$?)h+$W)T6Nhl7OkBQn@!h*DEdov&6N& z*m9nndn!G-@W|gDJDy4zNZqsb<#XjwPs#U1S($lyw2xr={^)>D0|}`GPH<m_ZGRk% z%=L16e>-%!cjQ9v?bAOx+lM~b(d5EpUs7kr`;vap=u1XX{rZ#if&6VZN(L*z9P6#@ z8_IY!rx8eSRHIG{zMYK2{R+kjFt39Z>zC6=zst^oFz+5Am*`7^Anb^;Apf01!aLh& X=~pzrjpqMUPYUYR#lH|tiAVncF-~Cc literal 0 HcmV?d00001 diff --git a/src/api/schemas/__pycache__/response.cpython-313.pyc b/src/api/schemas/__pycache__/response.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..180fd6e433c76410396a592327cf5ccfbd18df64 GIT binary patch literal 2464 zcmb_eOK;mo5GF;6qG&xVS#cfPiPw%3TZt{nkF<%J1aO_EvC|kqm>ycFU>S<C8B3(P zOC`2W270Iq^x#Vh_tb0i5Bf9W=AsZN8lWd%3L8#Qpr_6*Nx4p2pogxY;qL6r?9A*p zLmnlQ3I}6$VeAo>Iqny_2uC!S9DN3pr<}@RPUY3e9ez22Bg+C75M2v*#N{ZCE=yQq zYw=EOIfi418{oz`H9E+tQYCU(@NHq4abv)h|H6&0#`8-1Q{}!%nvP9OM2t1Fp%YYd zFml&Sq_3K`ix%!J6%^&cnnh5<skZ7Ss+u*+HVL}7S=DXVGAQ4y`+6XaaA1%Wlzr+! zKH|mS(}{VPih0pnmRYX?PrPXvt|u(mn|a=otGa8tR>L%WpM46+VdLlrcs%7Q9OfY* z5tYZnc}|UBu@Z%F1yxj|S0h-e#455XO@M5`k1>9n@#T<TQPjAqfP7CS0Xxb02+cWY z_H-pu_s_C(PY^Z1w0bKkkWErqHKnH2UNxg;)jl<+o+=1xzxq}|Eb=Az;fy+fv#Y1` zgI;ohrZP<QegbsczaA%^<qy@c(+F~IZkR^PwHzBVzu>yqdf0MJQdSU*FuP^Mj82d) z2Q^GW;L?~FfjJn^tZEsl{v0jcWOr*g_G1$hogRQ(tQ+f=y~>mtrmMq6=w<YN=m8k@ zd1%sg)=isCA$m<y$aIZDo@voFarH*CjKUC^4C5ioMjt&`R0{3hH~oFadp%W?7}#o3 zS3Efkt*sn;>?y~Qv?u<fThAh18c?dY>aJGP4cEb&m?qtm!)V&szq{(iyODUAM+B1R zSA=G6K$bk&+<+MCCTWi^Aw##(nptn6O{ayPSne8ntYgb*5#;J*ofHa%cCJNCJUufz zHyuPUJ%9Oa>qn8}_P?P3h^{u>>G{0iWxCg`Q3tdJ@p}ARYha@4MHxQ+$jz2!=ch|E z(=(SJ%#_M=rE;nCNnXTBFosi<WGKl3dB&~e`#f3GY#r=ro}y`B18NuM2~GRFrPqU< zw5HW8Ox!wDwC%ulOw+24p=lWW<d|(wATIVmP`7QzWvC&rPfzs3D%7xlE-fM~Q4*s& zQp+`L=LvJiORxwu0A9^n^-gITKm!_wrgyOo1_wdy2>zUB6dSf%tQPO8OXwbU9+`$q ziU4dIZWBX9PaM3iIfhky2-tOMHOnxmZ2+}vRh?s*BEd#cZ(7BmvlT;t6`Gqk4ks}} zfkD0l@+Ef|k)!fqmOC}Ld+zzA>wCEyTer837vhbV{X@Hp-&gkf%UjDk!V9tda%8+S zFx<&qKa2`U0;%fG+^(>5<tzJ$6Qa_g%nhN=0QxTdZMu`21fd~mYiWCfZ5|}J_;4rD z{|epej9qy(J=2-E*cqKXjESTD2MRZK<uDV^WDkU%)2Ra?c~&_P(mCmi5AlW2;}boS zrkswWuYvr3J@S4(vwDIAeKS!}!fpvYF&TEjf)Mn*q?*O4)xLbrOWij0y1N#Z+Ws7v zYws#P7s2bsSnqbPe`s|SB`rex7k&(PjQlm4Tw_)@r%=ldrhX%$T@4z^u|mLjvr8?q zgS*9k+@ew^Pr^OmuP6D(w$tHeVAYESlXm(IgE$R(4EeZ^&T`aDEO50Qohg(GC43r~ zc>!Od0#lUG!evFUl279yx)`S93?=k+ghwc0WyzEfU5--1)M?e>bCfVc<8*eO5?WV2 zcIZ0`=R-m-gE&Aa?qh)aUaoIoS9qSkx|c0)-P&HHFb7WG^Na89^<CTgU|W45UVAw> z(#7@pf5G+HiO$G{&geUUXo_Qr;#j6QR*rE@-B`&hEGSQI2A>j`ezLF_J;~i{!g~py z>??lRvKUUr<;mCl7Iy=ezy=S0QUG!w@H~GQ5qa_V1jk?anH&CzJM&r^=cS#c*Bm@v QukeGg`1m&t9xP9P0#|UJc>n+a literal 0 HcmV?d00001 diff --git a/src/api/schemas/__pycache__/siebel_schemas.cpython-313.pyc b/src/api/schemas/__pycache__/siebel_schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..759371a4709f7b237cdac27b1145494b154c7e2f GIT binary patch literal 11454 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 aSm1}Z9=zn>^3s2fcW-BZ%fW@d;r{^~tykFq literal 0 HcmV?d00001 diff --git a/src/api/schemas/__pycache__/tais_kb_schemas.cpython-313.pyc b/src/api/schemas/__pycache__/tais_kb_schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ab4dc36c8c144ec2857612be057f564fb5193d52 GIT binary patch literal 3848 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 R=Ct@gY~B5jz^;5n{{tGOo7?~Z literal 0 HcmV?d00001 diff --git a/src/api/schemas/abrt_schemas.py b/src/api/schemas/abrt_schemas.py new file mode 100644 index 0000000..0348add --- /dev/null +++ b/src/api/schemas/abrt_schemas.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class AbrtRequest(BaseModel): + socialSecNo: str = Field(..., description="Customer's social security number (CPF)") + msisdn: str = Field(..., description="Customer's MSISDN") + flagPre: bool = Field(True, description="Indicates prepaid query") + flagPos: bool = Field(True, description="Indicates postpaid query") + + +class AbrtResponse(BaseModel): + status: Optional[str] = Field(None, description="Status da consulta") + company: Optional[str] = Field(None, description="Operadora do acesso") + active: Optional[bool] = Field(None, description="Indica se o acesso está ativo") + planTypeId: Optional[str] = Field(None, description="Tipo de plano (0=pos, 1=pre)") \ No newline at end of file diff --git a/src/api/schemas/anatel_response_emulator_schemas.py b/src/api/schemas/anatel_response_emulator_schemas.py new file mode 100644 index 0000000..7d0cd84 --- /dev/null +++ b/src/api/schemas/anatel_response_emulator_schemas.py @@ -0,0 +1,165 @@ +"""Schemas for the Response Emulator flow. + +Modeled after the CMS "Tarefas Executadas" spreadsheet — the hierarchical +catalog the human operator fills in after handling an Anatel complaint. + +A selected action is one of two variants discriminated by `type`: + + PredefinedAction (type="predefined") — catalog item with an answer tree + └─ answers: { <question_id>: AnswerNode } + AnswerNode (leaf) = { "value": <primitive> } + AnswerNode (branch) = { "value": "<option_id>", + "<option_id>": { <sub_question_id>: AnswerNode, ... } } + When a chosen option carries a sub-form, the sub-answers live under a + key whose name matches the chosen `value`. Leaves only have `value`. + + CustomAction (type="custom") — operator's free-form note carrying both + context about what was done and steering hints for the LLM. +""" + +from typing import Annotated, Dict, Literal, Optional, Union +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +AnswerValue = Union[str, int, float, bool, None] + + +class AnswerNode(BaseModel): + """One node in the operator's answer tree. + + - Leaf: only `value` is set. + - Branch: `value` names the chosen option AND a sibling key with that + same name holds the sub-answers (dict of name → AnswerNode). + Sub-answers are kept loose (`extra='allow'`) because their + keys are dynamic (the option's id). + """ + + value: AnswerValue = None + model_config = ConfigDict(extra="allow") + + +# --- Operator-submitted actions --- + +class PredefinedAction(BaseModel): + """Catalog-listed task picked and filled by the operator in CMS.""" + + type: Literal["predefined"] + action_id: str + action_title: str + answers: Dict[str, AnswerNode] = Field(default_factory=dict) + + +class CustomAction(BaseModel): + """Operator's free-form note. Carries factual context AND steering hints.""" + + type: Literal["custom"] + action_title: Optional[str] = None + custom_text: str = Field(..., min_length=1) + + +SelectedAction = Annotated[ + Union[PredefinedAction, CustomAction], + Field(discriminator="type"), +] + + +# --- Emulator event envelope --- + +EmulationType = Literal["first_response", "regenerate"] +FlowMode = Literal["generate", "approve", "close"] + +EmulatorStatus = Literal[ + # In-flight sentinels — set by `start_response_emulation_node._flip_status_in_db` + # so the simulator's polling sees movement while the graph runs. + "processing", + "processing_regeneration", + # Terminal outcomes published by the emulator's terminal TicketResponseEvent. + "awaiting_review", + "approved", + "done", + "failed", +] + + +class OperatorFeedback(BaseModel): + """Operator's comment when rejecting a generated response.""" + comment: str + + +class ResponseEmulatorRequestEvent(BaseModel): + """Internal contract between the REST routes / streaming consumer and + `emulator_graph`. The CMS publishes one of these (wrapped in + StreamEnvelope) per operator action. + """ + + transactionId: str + type: EmulationType + flow_mode: FlowMode + selected_actions: list[SelectedAction] + previous_response: Optional[str] = None + feedback: Optional[OperatorFeedback] = None + + @model_validator(mode="after") + def _require_regenerate_fields(self) -> "ResponseEmulatorRequestEvent": + if self.type == "regenerate": + if not self.previous_response or not self.feedback: + raise ValueError( + "type='regenerate' requires `previous_response` and `feedback` in the payload" + ) + return self + + +# --- REST request bodies --- + +class EmulatorGenerateRequest(BaseModel): + """Body for POST /case/{transactionId}/response-emulator/generate.""" + + transactionId: str + action: Literal["generate", "regenerate"] + selected_actions: Optional[list[SelectedAction]] = None + operator_instructions: Optional[str] = None + + @model_validator(mode="after") + def _validate_action_fields(self) -> "EmulatorGenerateRequest": + if self.action == "generate": + if not self.selected_actions: + raise ValueError("`selected_actions` is required when action='generate'") + else: + if not (self.operator_instructions or "").strip(): + raise ValueError("`operator_instructions` is required when action='regenerate'") + return self + + +class EmulatorFinalizeRequest(BaseModel): + """Body for POST /case/{transactionId}/response-emulator/finalize.""" + + transactionId: str + action: Literal["approve", "close"] + + +# --- Status / transitions --- + +TransitionEvent = Literal["generated", "regenerated", "approved", "closed"] + + +class Transition(BaseModel): + event: TransitionEvent + at: str + from_status: Optional[str] = None + to_status: str + attempt: Optional[int] = None + actor: Optional[str] = None + + +class EmulatorStatusResponse(BaseModel): + """Response body for GET /case/{transactionId}/response-emulator.""" + + transactionId: str + status: Optional[EmulatorStatus] = None + current_step: Optional[str] = None + case_response: Optional[str] = None + validation: Optional[dict] = None + selected_actions_count: int = 0 + regenerate_count: int = 0 + transitions: list[Transition] = Field(default_factory=list) + last_updated_at: Optional[str] = None diff --git a/src/api/schemas/anatel_schemas.py b/src/api/schemas/anatel_schemas.py new file mode 100644 index 0000000..6106d09 --- /dev/null +++ b/src/api/schemas/anatel_schemas.py @@ -0,0 +1,313 @@ +from pydantic import BaseModel, Field, field_validator +from typing import List, Literal, Optional, Any, Dict, Union +from datetime import datetime +from enum import Enum + +from src.api.schemas.anatel_response_emulator_schemas import ResponseEmulatorRequestEvent + + + +# --- Validation / Error schemas --- + +class ReasonCode(str, Enum): + MISSING_CPF_CNPJ = "MISSING_CPF_CNPJ" + MISSING_MOTIVE = "MISSING_MOTIVE" + MISSING_MODALITY = "MISSING_MODALITY" + MISSING_OPENED_AT = "MISSING_OPENED_AT" + MISSING_COMPLAINT_PROTOCOL = "MISSING_COMPLAINT_PROTOCOL" + MISSING_TICKET_ID = "MISSING_TICKET_ID" + FIELD_ERROR = "FIELD_ERROR" + INVALID_VALUE = "INVALID_VALUE" + MISSING_ADDRESS_CEP = "MISSING_ADDRESS_CEP" + MISSING_ADDRESS_STREET = "MISSING_ADDRESS_STREET" + MISSING_ADDRESS_NEIGHBORHOOD = "MISSING_ADDRESS_NEIGHBORHOOD" + MISSING_ADDRESS_CITY = "MISSING_ADDRESS_CITY" + MISSING_ADDRESS_STATE = "MISSING_ADDRESS_STATE" + MISSING_DESCRIPTION = "MISSING_DESCRIPTION" + MISSING_SUBSCRIBER_CPF = "MISSING_SUBSCRIBER_CPF" + MISSING_SUBSCRIBER_NAME = "MISSING_SUBSCRIBER_NAME" + + +class InputChannel(str, Enum): + ANATEL = "anatel" + + +class CaseType(str, Enum): + ANATEL = "anatel" + +# Global mapping for Pydantic location tuples to (ReasonCode, message) +ERROR_CODE_MAPPING = { + # Customer Identity + ("customer", "cpfCnpj"): (ReasonCode.MISSING_CPF_CNPJ, "Customer CPF/CNPJ is required"), + ("customer", "name"): (ReasonCode.FIELD_ERROR, "Customer name is required"), + ("customer", "odcCustomer"): (ReasonCode.FIELD_ERROR, "Customer odcCustomer indicator is required"), + ("customer", "contumazCustomer"): (ReasonCode.FIELD_ERROR, "Customer contumazCustomer indicator is required"), + + # Address + ("customer", "address", "cep"): (ReasonCode.MISSING_ADDRESS_CEP, "Address CEP is required"), + ("customer", "address", "street"): (ReasonCode.MISSING_ADDRESS_STREET, "Address street is required"), + ("customer", "address", "neighborhood"): (ReasonCode.MISSING_ADDRESS_NEIGHBORHOOD, "Address neighborhood is required"), + ("customer", "address", "city"): (ReasonCode.MISSING_ADDRESS_CITY, "Address city is required"), + ("customer", "address", "state"): (ReasonCode.MISSING_ADDRESS_STATE, "Address state (UF) is required"), + + # Subscriber + ("customer", "subscriber", "cpfCnpj"): (ReasonCode.MISSING_SUBSCRIBER_CPF, "Subscriber CPF/CNPJ is required"), + ("customer", "subscriber", "subscriberName"): (ReasonCode.MISSING_SUBSCRIBER_NAME, "Subscriber name is required"), + + # Complaint + ("complaint", "complaintProtocol"): (ReasonCode.MISSING_COMPLAINT_PROTOCOL, "Complaint protocol is required"), + ("complaint", "motive"): (ReasonCode.MISSING_MOTIVE, "Complaint motive is required"), + ("complaint", "modality"): (ReasonCode.MISSING_MODALITY, "Complaint modality is required"), + ("complaint", "openedAt"): (ReasonCode.MISSING_OPENED_AT, "Complaint openedAt date is required"), + ("complaint", "description"): (ReasonCode.MISSING_DESCRIPTION, "Complaint description is required"), + ("complaint", "actionType"): (ReasonCode.FIELD_ERROR, "Complaint actionType is required"), + ("complaint", "inputChannel"): (ReasonCode.FIELD_ERROR, "Complaint inputChannel is required"), + ("complaint", "service"): (ReasonCode.FIELD_ERROR, "Complaint service is required"), + ("complaint", "firstService"): (ReasonCode.FIELD_ERROR, "Complaint firstService is required"), + + # Origin & Meta + ("origin", "sourceSystem"): (ReasonCode.FIELD_ERROR, "Origin sourceSystem is required"), + ("origin", "submittedBy", "userId"): (ReasonCode.FIELD_ERROR, "Origin userId is required"), + ("origin", "submittedBy", "name"): (ReasonCode.FIELD_ERROR, "Origin submittedBy name is required"), + ("caseType",): (ReasonCode.FIELD_ERROR, "CaseType is required"), +} + +class ValidationErrorDetailMessage(BaseModel): + code: str + text: str + +class ValidationErrorDetail(BaseModel): + messages: List[ValidationErrorDetailMessage] + +class ValidationResult(BaseModel): + is_valid: bool + error_response: Optional[Dict[str, Any]] = None + validated_fields: List[str] = Field(default_factory=list) + missing_fields: List[str] = Field(default_factory=list) + + +# --- Swagger v0.0.5 aligned schemas --- + +class SubmittedBy(BaseModel): + userId: str + name: str + email: Optional[str] = None + +class Origin(BaseModel): + sourceSystem: str + submittedBy: SubmittedBy + +class Address(BaseModel): + cep: str + street: str + neighborhood: str + city: str + state: str + +class Subscriber(BaseModel): + cpfCnpj: str + subscriberName: str + contactPhone: Optional[str] = None + contactName: Optional[str] = None + +class Customer(BaseModel): + cpfCnpj: str + phones: List[str] + msisdn: Optional[str] = None + name: str + email: Optional[str] = None + govBrSeal: Optional[bool] = None + hasAttachment: Optional[bool] = None + odcCustomer: bool + contumazCustomer: bool + address: Address + subscriber: Subscriber + +class Complaint(BaseModel): + complaintProtocol: str + actionType: str # nova | reabertura + providerProtocol: Optional[str] = None + inputChannel: InputChannel + service: str + firstService: str + modality: str + motive: str + description: str + openedAt: datetime + dueAt: Optional[datetime] = None # readOnly + + @field_validator("inputChannel", mode="before") + @classmethod + def normalize_input_channel(cls, v): + if isinstance(v, str): + return v.lower() + return v + + +class ComplaintHistoryItem(BaseModel): + """Reclamação anterior do cliente, usada para enriquecer a análise atual.""" + complaintProtocol: str + status: str + actionType: str # nova | reabertura + providerProtocol: Optional[str] = None + openedAt: datetime + inputChannel: str + service: str + firstService: str + modality: str + motive: str + description: str + cpfCnpj: str + msisdn: Optional[str] = None + phones: Optional[List[str]] = None + + +class TicketRequestEvent(BaseModel): + """Schema for events consumed from Kafka (CMS → Agent direction).""" + caseType: CaseType + origin: Origin + crmProtocol: Optional[str] = None + ticketId: Optional[str] = None + customer: Customer + complaint: Complaint + complaintHistory: Optional[List[ComplaintHistoryItem]] = None + transactionId: Optional[str] = Field(default=None, alias="transactionId") + + model_config = {"populate_by_name": True} + + @field_validator("caseType", mode="before") + @classmethod + def normalize_case_type(cls, v): + if isinstance(v, str): + return v.lower() + return v + +class KeyType(str, Enum): + STRING = "string" + NUMBER = "number" + BOOLEAN = "boolean" + +class FieldsToUpdate(BaseModel): + keyType: KeyType + keyDesc: str + keyName: str + +class ForwardingDecision(BaseModel): + decision: str + complaint_description_analysis: str | None = None + target_operator: str + forwarding_reason: str + +class TreatmentDecisionDetails(BaseModel): + agent_type: str + target_agent: str + +class TreatmentDecision(BaseModel): + category: str + decision: TreatmentDecisionDetails + treatment_decision_reasoning: str + +# --- Processing & Response schemas (Agent → CMS direction) --- + +class ProcessingStatus(str, Enum): + PROCESSING = "processing" + OPENED_RECLASSIFICATION_SR = "opened_reclassification_sr" + OPENED_TREATMENT_SR = "opened_treatment_sr" + OPENED_CANCELATION_SR = "opened_cancelation_sr" + OPENED_FORWARDING_SR = "opened_forwarding_sr" + RESPONSE = "response" + DONE = "done" + FAILED = "failed" + # Emulator lifecycle states. + # `processing` (re-used from the checklist enum) marks the first-response + # generation in flight; `processing_regeneration` distinguishes a + # subsequent regenerate run from the previous `awaiting_review` so the + # frontend polling can detect the transition without inspecting + # `validation.feedback_registered`. + PROCESSING_REGENERATION = "processing_regeneration" + AWAITING_REVIEW = "awaiting_review" + APPROVED = "approved" + SIEBEL_CLOSING_FAILED = "siebel_closing_failed" + + +class ProcessingAction(str, Enum): + ATG = "atg" + RESPONSE = "response" + AWAIT_RESPONSE = "await_response" + RETRY = "retry" + UPDATE = "update" + + +class Processing(BaseModel): + """ + Agent processing results, including LLM reasoning and final status of the graph. + """ + status: ProcessingStatus + current_step: str # current graph state when processing/done/failed status occurs + action: ProcessingAction + note: Optional[str] = Field(default=None, alias="note") + crmProtocol: Optional[str] = Field(default=None, alias="crmProtocol") + fieldsToUpdate: Optional[List[FieldsToUpdate]] = Field(default=None, alias="fieldsToUpdate") + case_response: Optional[str] = None + # Emulator lifecycle history; left None by the checklist flow. + transitions: Optional[List[Dict[str, Any]]] = None + metadata: Optional[Dict[str, Any]] = Field(default_factory=dict) + + model_config = {"populate_by_name": True} + +class TicketResponseEvent(BaseModel): + """Schema for events published back to CMS after agent processing.""" + transactionId: str = Field(..., alias="transactionId") + processing: Processing + # Top-level case-doc metadata fields the CMS should $set with dot + # notation. Used by the emulator to persist `metadata.validation` and + # `metadata.selected_actions` outside `processing`. None for checklist. + metadata: Optional[Dict[str, Any]] = None + + model_config = {"populate_by_name": True} + + +# --- Progress event schemas (incremental sub-status during `processing`) --- + +class ProgressProcessing(BaseModel): + """ + Sub-status frame published while the main status is `processing`. + The CMS callback (`on_agent_response`) only requires `processing.status`; + the remaining fields are stored as-is via `$set`. + """ + status: ProcessingStatus = ProcessingStatus.PROCESSING + current_step: str + action: ProcessingAction = ProcessingAction.AWAIT_RESPONSE + note: Optional[str] = None + timestamp: str + + +class ProgressEvent(BaseModel): + """Incremental progress event emitted on every persistable GraphStep transition.""" + transactionId: str + processing: ProgressProcessing + + +# --- Stream envelope (CMS → Agent, discriminado por event_type) --- + +EventType = Literal["checklist", "response_emulator"] + + +class StreamEnvelope(BaseModel): + """Envelope discriminado das mensagens publicadas pelo CMS na fila OCI + Streaming. Transporta os dois fluxos: + + - checklist → TicketRequestEvent + - response_emulator → ResponseEmulatorRequestEvent + + A rota REST do emulador (POST /case/{tx}/response-emulator/{generate, + finalize}) continua funcionando para chamadas síncronas; o streaming é + o caminho assíncrono disparado pelo CMS. + + Mensagens "cruas" (sem envelope, formato legado do checklist) ainda são + suportadas pelo consumer via fallback retrocompat — ver + `src/infrastructure/streaming/consumer.py`. + """ + + event_type: EventType + data: Union[TicketRequestEvent, ResponseEmulatorRequestEvent] \ No newline at end of file diff --git a/src/api/schemas/emulator_rag_schemas.py b/src/api/schemas/emulator_rag_schemas.py new file mode 100644 index 0000000..2cd464b --- /dev/null +++ b/src/api/schemas/emulator_rag_schemas.py @@ -0,0 +1,26 @@ +"""Pydantic schemas for the emulator RAG test endpoint.""" + +from enum import Enum + +from pydantic import BaseModel + + +class EmulatorRagSourceEnum(str, Enum): + templates = "templates" + anatel_resposta = "anatel_resposta" + + +class EmulatorRagSearchResultItem(BaseModel): + id: str + content: str + distance: float + metadata: dict | None = None + + +class EmulatorRagSearchResponse(BaseModel): + results: list[EmulatorRagSearchResultItem] + total_results: int + source: EmulatorRagSourceEnum + query: str + top_k: int + sql: str diff --git a/src/api/schemas/imdb_schemas.py b/src/api/schemas/imdb_schemas.py new file mode 100644 index 0000000..1f9fcde --- /dev/null +++ b/src/api/schemas/imdb_schemas.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, Field +from typing import Dict, Any, Optional + +class ImdbRequest(BaseModel): + msisdn: Optional[str] = Field(None, description="Customer MSISDN") + client_id: str = Field(..., description="Client identification") + +class ImdbResponse(BaseModel): + plan: Optional[Dict[str, Any]] = Field(None, description="Customer's plan") + statusType: Optional[str] = Field(None, description="Status Type") + statusDescription: Optional[str] = Field(None, description="Status description") + socialSecNo: Optional[str] = Field( + None, + description="CPF/CNPJ do titular da linha conforme retornado pela Base TIM (IMDB)" + ) diff --git a/src/api/schemas/portability_schemas.py b/src/api/schemas/portability_schemas.py new file mode 100644 index 0000000..13d228e --- /dev/null +++ b/src/api/schemas/portability_schemas.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class PortabilityRequest(BaseModel): + msisdn: str = Field(..., description="Customer's MSISDN") + socialSecNo: str = Field(..., description="Customer's social security number (CPF)") + + +class PortabilityItem(BaseModel): + socialSecNo: Optional[str] = Field(None, description="CPF associado ao registro de portabilidade") + msisdn: Optional[str] = Field(None, description="MSISDN do registro de portabilidade") + activationWindow: Optional[str] = Field(None, description="Janela de ativação da portabilidade (ISO 8601)") + operatorsName: Optional[str] = Field(None, description="Operadora de destino (recipiente) da portabilidade") + operatorsEot: Optional[str] = Field(None, description="Código EOT da operadora de destino") + operatorsCode: Optional[str] = Field(None, description="Código da operadora de destino") + operatorsDonorName: Optional[str] = Field(None, description="Operadora de origem (doadora) da portabilidade") + protocol: Optional[str] = Field(None, description="Protocolo da portabilidade") + status: Optional[str] = Field(None, description="Status do registro de portabilidade") + requestedDate: Optional[str] = Field(None, description="Data de solicitação da portabilidade (ISO 8601)") + cancellationDate: Optional[str] = Field(None, description="Data de efetivação/cancelamento da portabilidade (ISO 8601)") + cancellationDesc: Optional[str] = Field(None, description="Descrição do motivo de cancelamento") + + +class PortabilityResponse(BaseModel): + msisdn: Optional[str] = Field(None, description="Customer's MSISDN") + socialSecNo: Optional[str] = Field(None, description="Customer's social security number (CPF)") + statusType: Optional[str] = Field(None, description="Status type returned by Portability API") + statusDescription: Optional[str] = Field(None, description="Status description returned by Portability API") + portabilities: list[PortabilityItem] = Field(default_factory=list, description="Histórico de portabilidades do número") \ No newline at end of file diff --git a/src/api/schemas/request.py b/src/api/schemas/request.py new file mode 100644 index 0000000..0f55940 --- /dev/null +++ b/src/api/schemas/request.py @@ -0,0 +1,53 @@ +""" +Request schemas for the agent API. + +This module defines Pydantic models for validating incoming requests. +""" + +from pydantic import BaseModel, Field, field_validator +from typing import Optional, Dict, Any + + +class AgentRequest(BaseModel): + """ + Request model for agent execution. + + Attributes: + message: User message to process (required, 1-10000 characters) + session_id: Optional session ID for conversation continuity + context: Optional additional context for the agent + """ + message: str = Field( + ..., + min_length=1, + max_length=10000, + description="User message to process" + ) + session_id: Optional[str] = Field( + None, + description="Session ID for conversation continuity" + ) + context: Optional[Dict[str, Any]] = Field( + default_factory=dict, + description="Additional context for the agent" + ) + + @field_validator('message') + @classmethod + def message_not_empty(cls, v: str) -> str: + """Validate that message is not empty or whitespace only.""" + if not v.strip(): + raise ValueError('Message cannot be empty or whitespace only') + return v.strip() + + model_config = { + "json_schema_extra": { + "examples": [ + { + "message": "Hello, how can you help me?", + "session_id": "user-123-session-456", + "context": {"user_id": "123", "language": "en"} + } + ] + } + } diff --git a/src/api/schemas/response.py b/src/api/schemas/response.py new file mode 100644 index 0000000..f8f3ac7 --- /dev/null +++ b/src/api/schemas/response.py @@ -0,0 +1,77 @@ +""" +Response schemas for the agent API. + +This module defines Pydantic models for API responses. +""" + +from pydantic import BaseModel, Field +from typing import Dict, Any +from datetime import datetime + + +class AgentResponse(BaseModel): + """ + Response model from agent execution. + + Attributes: + response: Agent's response message + session_id: Session ID for conversation tracking + metadata: Execution metadata (tokens, time, etc.) + timestamp: Response timestamp in UTC + """ + response: str = Field(..., description="Agent's response") + session_id: str = Field(..., description="Session ID") + metadata: Dict[str, Any] = Field( + default_factory=dict, + description="Execution metadata (tokens, time, etc.)" + ) + timestamp: datetime = Field( + default_factory=datetime.utcnow, + description="Response timestamp" + ) + + model_config = { + "json_schema_extra": { + "examples": [ + { + "response": "I can help you with various tasks...", + "session_id": "user-123-session-456", + "metadata": { + "execution_time_ms": 1234, + "tokens_used": 150, + "model": "gpt-4" + }, + "timestamp": "2024-01-15T10:30:00Z" + } + ] + } + } + + +class HealthResponse(BaseModel): + """ + Health check response. + + Attributes: + status: Service status (healthy, unhealthy) + version: Service version + timestamp: Check timestamp in UTC + """ + status: str = Field(..., description="Service status") + version: str = Field(..., description="Service version") + timestamp: datetime = Field( + default_factory=datetime.utcnow, + description="Check timestamp" + ) + + model_config = { + "json_schema_extra": { + "examples": [ + { + "status": "healthy", + "version": "1.0.0", + "timestamp": "2024-01-15T10:30:00Z" + } + ] + } + } diff --git a/src/api/schemas/siebel_schemas.py b/src/api/schemas/siebel_schemas.py new file mode 100644 index 0000000..7818734 --- /dev/null +++ b/src/api/schemas/siebel_schemas.py @@ -0,0 +1,230 @@ +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any, Literal +from src.utils.text import truncate_text + + +class SiebelSRRequest(BaseModel): + """ + DTO representing the full payload for a Siebel Service Request. + + Dynamic fields (required): social_sec_no, asset_id, reason1, reason2, reason3. + Static/mocked fields: have defaults and do not need to be provided by callers as of Sprint 1. + """ + + # Top-level fields + channel: str = Field(default="Anatel", description="Channel") + social_sec_no: str = Field(..., description="Customer CPF or CNPJ (socialSecNo)") + asset_id: str = Field(..., description="Customer MSISDN (assetId)") + + # serviceRequest fields + sr_type: str = Field(default="0119", description="Service Request type") + user_id: str = Field(default="SADMIN", description="User ID") + reason1: str = Field(..., description="Level 1 reason") + reason2: str = Field(..., description="Level 2 reason") + reason3: str = Field(..., description="Level 3 reason") + status: str = Field(default="Encaminhado", description="Service Request status") + notes: str = Field(default="Notas", description="Additional notes") + + # interaction fields + interaction_source: str = Field(default="Cliente", description="Interaction source") + interaction_request_flag: bool = Field(default=False, description="Interaction request flag") + interaction_direction_contact: str = Field(default="FROM-CLIENT", description="Direction of contact") + interaction_status: str = Field(default="OPENED", description="Interaction status") + + @staticmethod + def build_notes( + type: Literal["tratamento", "reclassificar", "cancelar", "reencaminhar"], + data: str, + protocolo_anatel: str, + acao: str, + cpf_cliente: str, + nome_cliente: str, + telefone_reclamado: str, + telefone_contato: str, + endereco: str, + cep: str, + cidade: str, + estado: str, + servico: str, + primeiro_servico: str, + modalidade: str, + motivo: str, + descricao_reclamacao: str, + motivo_extra: Optional[str] = None, + ) -> str: + """ + Builds the notes field following the template. + """ + + header_map = { + "tratamento": "Atendimento criado para a seguinte reclamação Anatel:", + "reclassificar": "Atendimento criado para registrar a solicitação de reclassificação na Anatel:", + "cancelar": "Atendimento criado para registrar a solicitação de cancelamento na Anatel:", + "reencaminhar": "Atendimento criado para registrar a solicitação de reencaminhamento na Anatel:", + } + + notes = f"""{header_map[type]} | +Data: {data} | +Protocolo Anatel: {protocolo_anatel} | +Ação: {acao} | +CPF Cliente: {cpf_cliente} | +Nome Cliente: {nome_cliente} | +Telefone Reclamado: {telefone_reclamado} | +Telefone Contato: {telefone_contato} | +Endereço: {endereco} | +CEP: {cep} | +Cidade: {cidade} | +Estado: {estado} | +Serviço: {servico} | +Primeiro Serviço: {primeiro_servico} | +Modalidade: {modalidade} | +Motivo: {motivo} | +Descrição Reclamação: {descricao_reclamacao} | +""".replace("\n", "") + + label_map = { + "cancelar": "Motivo do Cancelamento", + "reclassificar": "Motivo da Reclassificação", + "reencaminhar": "Motivo do Reencaminhamento", + } + if type in label_map and motivo_extra: + notes += f"{label_map[type]}: {motivo_extra}" + + # Truncate to Siebel limit (1400 chars) + return truncate_text(notes.strip(), 1400) + + def to_payload(self) -> Dict[str, Any]: + """Serializes the DTO into the dict format expected by SiebelClient.""" + return { + "channel": self.channel, + "socialSecNo": self.social_sec_no, + "assetId": self.asset_id, + "serviceRequest": { + "type": self.sr_type, + "userId": self.user_id, + "reason1": self.reason1, + "reason2": self.reason2, + "reason3": self.reason3, + "status": self.status, + "notes": self.notes, + }, + "interaction": { + "source": self.interaction_source, + "requestFlag": self.interaction_request_flag, + "directionContact": self.interaction_direction_contact, + "status": self.interaction_status, + }, + } + + +class SiebelSRStatusRequestPosPago(BaseModel): + """ + DTO for the Siebel statusServiceRequest payload (SR closing — pós-pago). + """ + + channel: str = Field(default="Anatel") + protocol_number: str = Field(..., description="Número da SR no Siebel (interactionProtocol)") + status: str = Field(default="Fechado") + notes: str = Field(...) + date: str = Field(..., description="Data no formato MM/DD/YYYY HH:MM:SS") + + @staticmethod + def build_notes(complaint_protocol: str, accepted_response: str) -> str: + return ( + f"Atendimento finalizado para a reclamação Anatel {complaint_protocol}" + f" com a seguinte resposta: {accepted_response}" + ) + + def to_payload(self) -> Dict[str, Any]: + return { + "channel": self.channel, + "serviceRequest": { + "status": self.status, + "notes": self.notes, + "protocolNumber": self.protocol_number, + }, + "date": self.date, + } + + +class SiebelSRStatusRequestPrePago(BaseModel): + """ + DTO for the Siebel pré-pago SR closing payload (PATCH /customers/v1/serviceRequest). + """ + + protocol: str = Field(..., description="Número da SR no Siebel (interactionProtocol)") + msisdn: str = Field(..., description="MSISDN do cliente") + status: str = Field(default="Closed") + close_date: str = Field(..., description="Data no formato DD-MM-YYYY HH:MM:SS") + notes: str = Field(...) + + def to_payload(self) -> Dict[str, Any]: + return { + "protocol": self.protocol, + "msisdn": self.msisdn, + "status": self.status, + "closeDate": self.close_date, + "notes": self.notes, + } + + +class SiebelSRResponse(BaseModel): + success: bool + message: str + service_request_number: Optional[str] = None + data: Optional[Dict[str, Any]] = None + + +class SiebelProspectSRRequest(BaseModel): + """ + DTO representing the payload for the Siebel Prospect SR API + (used when IMDB indicates non-TIM number or canceled customer). + """ + + # Customer dynamic fields + name: str = Field(..., description="Customer full name") + document_number: str = Field(..., description="Customer CPF/CNPJ (interaction.documentNumber)") + email: Optional[str] = Field(default=None, description="Customer email") + phone1: Optional[str] = Field(default=None, description="Customer contact phone") + + # SR dynamic fields + reason1: str = Field(..., description="Level 1 reason") + reason2: str = Field(..., description="Level 2 reason") + reason3: str = Field(..., description="Level 3 reason") + notes: str = Field(..., description="SR notes") + + # Static / configurable fields + login: str = Field(default="SADMIN", description="Login") + customer_type: str = Field(default="PF", description="Customer type (PF/PJ) - Pessoa Física ou Jurídica") + user_doc_num_pdv: str = Field(default="", description="PDV number document") + user_name_pdv: str = Field(default="", description="PDV user name — to be confirmed") + cust_code_pdv: str = Field(default="", description="PDV customer code — to be confirmed") + channel: str = Field(default="Anatel", description="Channel") + sr_status: str = Field(default="Encaminhado", description="Service Request status, for new opening requests use 'Encaminhado'") + status: str = Field(default="OPENED", description="Interaction status, for new opening requests use 'OPENED'") + + def to_payload(self) -> Dict[str, Any]: + """Serializes the DTO into the dict format expected by the Prospect API.""" + return { + "login": self.login, + "customer": { + "msisdn": "", + "type": self.customer_type, + "name": self.name, + "email": {"email": self.email or ""}, + "contact": {"phone1": self.phone1 or ""}, + }, + "interaction": { + "userDocNumPdv": self.user_doc_num_pdv, + "userNamePdv": self.user_name_pdv, + "documentNumber": self.document_number, + "reason1": self.reason1, + "reason2": self.reason2, + "reason3": self.reason3, + "channel": self.channel, + "notes": self.notes, + "srStatus": self.sr_status, + "custCodePdv": self.cust_code_pdv, + "status": self.status, + }, + } diff --git a/src/api/schemas/tais_kb_schemas.py b/src/api/schemas/tais_kb_schemas.py new file mode 100644 index 0000000..b8b44f6 --- /dev/null +++ b/src/api/schemas/tais_kb_schemas.py @@ -0,0 +1,59 @@ +""" +Response schemas for the TAIS Knowledge Base API. + +This module defines Pydantic models for TAIS KB search responses. +""" + +from pydantic import BaseModel, Field +from typing import List + + +class TaisKbSearchResultItem(BaseModel): + """ + Single document result from TAIS KB search. + + Attributes: + id_proc: Document ID + title_proc: Document title/procedure name + description_proc: Document description + content: Document content + segment: Document segment classification + sub_segments: Document sub-segment classification + distance: Cosine distance (0-1, lower is better) + """ + id_proc: str = Field(..., description="Document ID") + title_proc: str = Field(..., description="Document title/procedure name") + description_proc: str = Field(..., description="Document description") + content: str = Field(..., description="Document content") + segment: str = Field(..., description="Document segment classification") + sub_segments: str = Field(..., description="Document sub-segment classification") + distance: float = Field(..., description="Cosine distance (0-1, lower is more similar)") + + +class TaisKbSearchResponse(BaseModel): + """ + Response model from TAIS KB search. + + Attributes: + results: List of search results + total_results: Number of results returned + query: Original query text + reformulated_query: Query after preprocessing with OCI GenAI (if preprocess=true) + postprocessing_content: Synthesized answer from LLM postprocessing (if postprocess=true) + postprocessing_id_procs: List of document IDs used in postprocessing (if postprocess=true) + postprocessing_id_procs_map: Mapping of id_proc to title_proc for postprocessing results (if postprocess=true) + postprocessing_prompt: Complete filled prompt sent to LLM for postprocessing (if postprocess=true) + sql: SQL query that was executed + """ + results: List[TaisKbSearchResultItem] = Field( + ..., + description="Search results sorted by relevance (lowest distance first)" + ) + total_results: int = Field(..., description="Number of results returned") + query: str = Field(..., description="Original query text") + reformulated_query: str | None = Field(None, description="Query after preprocessing with OCI GenAI (if preprocess=true)") + postprocessing_content: str | None = Field(None, description="Synthesized answer from LLM postprocessing (if postprocess=true)") + postprocessing_id_procs: list[str] | None = Field(None, description="Document IDs used in postprocessing response (if postprocess=true)") + postprocessing_id_procs_map: dict[str, str] | None = Field(None, description="Mapping of id_proc to title_proc for postprocessing results (if postprocess=true)") + postprocessing_prompt: str | None = Field(None, description="Complete filled prompt sent to LLM for postprocessing (if postprocess=true)") + sql: str = Field(..., description="SQL query that was executed") \ No newline at end of file diff --git a/src/api/utils/__pycache__/agent_helpers.cpython-313.pyc b/src/api/utils/__pycache__/agent_helpers.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5e1051e29b8e916bc3eab9937777d760c50b6e1f GIT binary patch literal 9751 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 Sh2IYh>N|gV_E&^J6!^bs%`P_p literal 0 HcmV?d00001 diff --git a/src/api/utils/__pycache__/emulator_response_builder.cpython-313.pyc b/src/api/utils/__pycache__/emulator_response_builder.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..75034e6eb4094fca1df6e8c43aa3f7887ad12dbb GIT binary patch literal 9346 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 L>Hncuejxt~JS~jt literal 0 HcmV?d00001 diff --git a/src/api/utils/agent_helpers.py b/src/api/utils/agent_helpers.py new file mode 100644 index 0000000..528242f --- /dev/null +++ b/src/api/utils/agent_helpers.py @@ -0,0 +1,230 @@ +import json +import logging +from src.agent.state.agent_state import AgentState +from src.agent.state.steps import GraphStep +from typing import Any, Optional, Tuple +from enum import Enum +from fastapi import status +from fastapi.responses import JSONResponse +from src.api.schemas.anatel_schemas import TicketResponseEvent, Processing, FieldsToUpdate, KeyType +from src.agent.state.agent_state import get_last_ai_message +from src.utils.external_response_builder import build_external_response + +logger = logging.getLogger(__name__) + + +def create_error_response( + status_code: int, + correlation_id: str, + final_response: str, + parsed_response: dict = None, + metadata: dict = None, +) -> JSONResponse: + """Standardized error envelope used by agent routes.""" + if isinstance(parsed_response, dict): + error_payload = { + "title": parsed_response.get("title", "execution error"), + "status": parsed_response.get("status", status_code), + "correlation_id": correlation_id, + "detail": parsed_response.get("detail", {"messages": []}), + } + else: + error_payload = { + "title": "system error" if status_code >= 500 else "validation error", + "status": status_code, + "correlation_id": correlation_id, + "detail": { + "messages": [{"code": "EXECUTION_ERROR", "text": str(final_response)}] + }, + } + + if metadata: + error_payload["metadata"] = metadata + + return JSONResponse(status_code=status_code, content=error_payload) + +_SR_STATUSES = [ + "opened_reclassification_sr", + "opened_treatment_sr", + "opened_cancelation_sr", + "opened_forwarding_sr" +] + + +def build_ticket_tags(event_context: dict, complaint_context: dict) -> list[str]: + """Builds Langfuse tags from ticket event fields, used before graph execution.""" + case_type = event_context.get("caseType", "unknown") + service = complaint_context.get("service", "unknown") + modality = complaint_context.get("modality", "unknown") + action_type = complaint_context.get("actionType", "unknown") + gov_br_seal = event_context.get("customer", {}).get("govBrSeal") or False + + return [ + f"caseType: {case_type.value.capitalize() if isinstance(case_type, Enum) else case_type}", + f"service: {service}", + f"actionType: {action_type.capitalize()}", + f"govBrSeal: {str(gov_br_seal).lower()}", + ] + + +def resolve_outcome_tag(current_step: str, error_info: Optional[dict]) -> str: + """Returns the outcome tag based on graph execution result, used after graph execution.""" + if current_step == GraphStep.VALIDATION_FAILED: + return "outcome:validation_failed" + if error_info: + return "outcome:failed" + return "outcome:completed" + + +def extract_response_payload(state: AgentState) -> Tuple[str, Any]: + """ + Standardizes the extraction of the final response from agent state. + Returns a tuple of (raw_string, parsed_json_or_string). + """ + final_response = state.get("final_response") + if not final_response: + last_ai_msg = get_last_ai_message(state) + final_response = last_ai_msg.content if last_ai_msg else "No response generated" + + parsed = None + try: + parsed = json.loads(final_response) + except Exception: + parsed = final_response + + return final_response, parsed + +def get_http_status_code(state: AgentState, parsed_response: Any) -> int: + """ + Determines the appropriate HTTP status code based on state and response content. + """ + current_step = state.get("current_step", "unknown") + error_state = state.get("error") + + is_error = bool(error_state) or current_step == GraphStep.VALIDATION_FAILED + status_code = status.HTTP_200_OK + + # Try to extract status code from parsed response if it's a dict + if isinstance(parsed_response, dict) and "status" in parsed_response: + try: + resp_status = int(parsed_response.get("status")) + if resp_status >= 400: + is_error = True + status_code = resp_status + except (ValueError, TypeError): + pass + + if is_error and status_code == status.HTTP_200_OK: + is_val_err = (current_step == GraphStep.VALIDATION_FAILED or + (error_state and error_state.get("type") == "ValidationError")) + status_code = status.HTTP_400_BAD_REQUEST if is_val_err else status.HTTP_500_INTERNAL_SERVER_ERROR + + return status_code + +def build_cms_response_event(state: AgentState, correlation_id: str) -> TicketResponseEvent: + """ + Unified logic to build the TicketResponseEvent for both API and Background Worker. + """ + context = state.get("metadata", {}).get("request_context", {}) + sr_data = context.get("siebel_sr_data", {}) + sr_number = sr_data.get("interactionProtocol", "N/A") + decision = context.get("siebel_action", "done") + current_step = state.get("current_step", "unknown") + + status_mapping = { + "reclassificar": "opened_reclassification_sr", + "tratamento": "opened_treatment_sr", + "cancelar": "opened_cancelation_sr", + "reencaminhar": "opened_forwarding_sr" + } + + if current_step == GraphStep.VALIDATION_FAILED or sr_number == "N/A": + cms_status = "failed" + else: + cms_status = status_mapping.get(decision.lower() if isinstance(decision, str) else "done", "done") + + # Determine response action + if cms_status in _SR_STATUSES: + action = "update" + elif sr_number != "N/A": + action = "response" + else: + action = "atg" + + # Build fieldsToUpdate for reclassification + fields_to_update = None + if cms_status == "opened_reclassification_sr": + fields_to_update = [ + FieldsToUpdate(keyType=KeyType.STRING, keyDesc="Motivo/Problema da reclamação a ser corrigido", keyName="motive"), + FieldsToUpdate(keyType=KeyType.STRING, keyDesc="Modalidade/Assunto da reclamação a ser corrigida", keyName="modality") + ] + + # Build enhanced metadata + metadata = { + "siebel_action": decision, + } + + for key in ("canceling_decision", "forwarding_decision", "reclassification_decision", "treatment_decision"): + if context.get(key): + metadata[key] = context.get(key) + + # Add knowledge base enrichment data if available + relevant_docs = context.get("relevant_documents") + if relevant_docs: + kb_payload = { + "query": relevant_docs.get("query"), + "documents": relevant_docs.get("documents") or [], + } + if relevant_docs.get("message"): + kb_payload["message"] = relevant_docs["message"] + if relevant_docs.get("postprocessing_id_procs_map"): + kb_payload["postprocessing_id_procs_map"] = relevant_docs["postprocessing_id_procs_map"] + metadata["relevant_documents"] = kb_payload + + # Add speech enrichment data if available + speech = context.get("speech_analytics") + if speech: + atual_keys = ( + "reclamacao_resumo", + "causa_raiz", + "descortesia_cliente", + "motivo_reclamacao", + "submotivo_reclamacao", + "sentimento_cliente", + "solucao_proposta_cliente", + ) + related_keys = ("protocolo", "data_reclamacao", "similaridade_pct") + atual_keys + + historico_relacionado = [ + {k: item.get(k) for k in related_keys} + for item in (speech.get("historico_relacionado") or []) + ] + metadata["speech_retrieved_data"] = { + "reclamacao_atual": {k: speech.get(k) for k in atual_keys}, + "historico": { + "relacionado": historico_relacionado or [], + "analise_agente": speech.get("analise_agente"), + }, + } + + # Include execution markers + metadata.update({ + "iteration_count": state.get("iteration_count", 0), + "error": state.get("error") + }) + + case_response = build_external_response(context, decision.lower() if isinstance(decision, str) else "") + + return TicketResponseEvent( + transactionId=correlation_id, + processing=Processing( + status=cms_status, + current_step=current_step, + action=action, + note=state.get("processing_notes") or None, + crmProtocol=sr_number if sr_number != "N/A" else None, + fieldsToUpdate=fields_to_update, + case_response=case_response, + metadata=metadata + ) + ) diff --git a/src/api/utils/emulator_response_builder.py b/src/api/utils/emulator_response_builder.py new file mode 100644 index 0000000..e5e42ce --- /dev/null +++ b/src/api/utils/emulator_response_builder.py @@ -0,0 +1,202 @@ +"""Builds the `TicketResponseEvent` for the Response Emulator flow. + +Kept separate from `agent_helpers.build_cms_response_event` (checklist's +builder) to avoid coupling the two domains. Status mapping (status,action): + error at any step → failed, retry + flow_mode="generate" → awaiting_review, await_response + flow_mode="approve" → approved, await_response + flow_mode="close" → done, response + +The event is meant to be the LAST message published on the OCI Response +Stream for a given case, so the CMS callback resolves the final DB state +without races against earlier ProgressEvents. + +## Where each field lives (split by lifetime) + +- `processing.metadata` (per-run scratch, lives only in the latest + `processing` subdoc): just `error` when the graph failed. Everything else + used to be here too and was duplicating the case-doc `metadata` below — + the GET endpoint already reads validation/selected_actions from the + case-level `metadata`, so keeping a parallel copy under `processing` was + pure dead weight that the CMS overwrote on every run. +- `metadata` (top-level case doc, persisted incrementally via `$set` with + dot-notation): selected_actions, validation, flow_mode, emulator_routing, + and the `last_emulation` summary. These survive across runs and merge + cleanly with whatever the checklist already wrote to `metadata`. +""" + +from datetime import datetime, timezone + +from src.agent.state.agent_state import AgentState +from src.agent.state.steps_emulator import EmulatorGraphStep +from src.api.schemas.anatel_schemas import ( + Processing, + ProcessingAction, + ProcessingStatus, + TicketResponseEvent, +) + + +# Maps flow_mode → (status, action) when the graph completes without error. +_FLOW_MODE_OUTCOMES: dict[str, tuple[ProcessingStatus, ProcessingAction]] = { + "generate": (ProcessingStatus.AWAITING_REVIEW, ProcessingAction.AWAIT_RESPONSE), + "approve": (ProcessingStatus.APPROVED, ProcessingAction.AWAIT_RESPONSE), + "close": (ProcessingStatus.DONE, ProcessingAction.RESPONSE), +} + + +def _resolve_crm_protocol(case_data: dict | None) -> str | None: + """Returns the persisted Siebel SR protocol, or None when no SR exists. + + Why this is restricted to two sources (and NOT `case_data.crmProtocol` + at the root or `request_context.crmProtocol`): the root-level field on + the request event is whatever the simulator/CMS sent in — for our test + payloads it's `"DS-XXXXXXXX"`, a ticketId-shaped placeholder, not the + real Anatel-style SR protocol (`"20260..."`) that Siebel returns when + the treatment SR opens. Falling back to it would propagate that bogus + value into `processing.crmProtocol` and the close PATCH would close + the wrong SR. + + Trustworthy sources, in order: + 1. `processing.crmProtocol` — written by the checklist's terminal + event from `siebel_sr_data.interactionProtocol`. Canonical. + 2. `siebel_sr_data.interactionProtocol` — in-memory only; relevant + when the checklist + emulator run in the same process (tests). + + The emulator MUST echo this back on every terminal event because the + CMS callback overwrites the `processing` subdoc; omitting the field + zeroes it, which is the bug that broke `close_case` before. + """ + case_data = case_data or {} + processing = case_data.get("processing") or {} + sr_data = case_data.get("siebel_sr_data") or {} + return processing.get("crmProtocol") or sr_data.get("interactionProtocol") + + +def _resolve_persisted_field( + case_data: dict | None, + new_value, + field: str, +): + """Returns `new_value` when truthy, else the persisted value in `case_data.processing.{field}`. + + Why: when a graph run fails (e.g. `approve_draft` errors because of a + transient issue), the state's `metadata.{field}` is empty for that + run. If we publish `Processing(field=None)`, the CMS callback wipes + the value the previous successful run had persisted — turning a + recoverable failure into permanent data loss (this is exactly what + nuked `case_response`/`transitions` mid-flow before this fix). When + the new run actually produced a value, that wins; otherwise we echo + what is already in the doc so the failure is non-destructive. + """ + if new_value: + return new_value + processing = (case_data or {}).get("processing") or {} + return processing.get(field) + + +def _resolve_outcome( + error_info: dict | None, + case_response: str | None, + flow_mode: str | None, +) -> tuple[ProcessingStatus, ProcessingAction]: + if error_info and flow_mode == "close": + return ProcessingStatus.SIEBEL_CLOSING_FAILED, ProcessingAction.RETRY + if error_info: + return ProcessingStatus.FAILED, ProcessingAction.RETRY + if flow_mode == "close" and not case_response: + return ProcessingStatus.FAILED, ProcessingAction.RETRY + if flow_mode == "generate" and not case_response: + return ProcessingStatus.FAILED, ProcessingAction.RETRY + return _FLOW_MODE_OUTCOMES.get( + flow_mode or "", + (ProcessingStatus.FAILED, ProcessingAction.RETRY), + ) + + +def _build_case_metadata( + selected_actions: list, + validation: dict, + flow_mode: str | None, + routing: dict | None, + emulation_type: str | None, + iteration_count: int, +) -> dict: + """Top-level case-doc metadata for the CMS to `$set` via dot notation. + + Empty/None values are omitted so a partial run (e.g. `approve` has no + new `selected_actions` or `validation`) doesn't blow away keys written + by an earlier run — this is the incremental-merge contract with the + CMS callback. `last_emulation` is always included as a fresh marker + of when the most recent graph run happened and what it was. + """ + case_metadata: dict = { + "last_emulation": { + "type": emulation_type, + "is_regeneration": emulation_type == "regenerate", + "flow_mode": flow_mode, + "iteration_count": iteration_count, + "at": datetime.now(timezone.utc).isoformat(), + }, + } + if selected_actions: + case_metadata["selected_actions"] = selected_actions + if validation: + case_metadata["validation"] = validation + if flow_mode: + case_metadata["flow_mode"] = flow_mode + if routing: + case_metadata["emulator_routing"] = routing + return case_metadata + + +def build_emulator_response_event(state: AgentState, transaction_id: str) -> TicketResponseEvent: + metadata = state.get("metadata", {}) or {} + request_context = metadata.get("request_context") or {} + case_response = metadata.get("case_response") or request_context.get("case_response") + current_step = str(state.get("current_step") or "") + error_info = state.get("error") + validation = metadata.get("validation") or {} + flow_mode = metadata.get("flow_mode") or request_context.get("flow_mode") + transitions = metadata.get("transitions") + selected_actions = metadata.get("selected_actions") or [] + routing = metadata.get("emulator_routing") + emulation_type = request_context.get("type") + iteration_count = state.get("iteration_count", 0) + case_data = metadata.get("case_data") or {} + crm_protocol = _resolve_crm_protocol(case_data) + # Echo persisted values when this run didn't produce new ones so a + # failed run doesn't blank out the previous successful draft. + case_response = _resolve_persisted_field(case_data, case_response, "case_response") + transitions = _resolve_persisted_field(case_data, transitions, "transitions") + + cms_status, action = _resolve_outcome(error_info, case_response, flow_mode) + + # Per-run scratch only — case-level info goes under top-level `metadata`. + response_metadata: dict = {} + if error_info: + response_metadata["error"] = error_info + + case_metadata = _build_case_metadata( + selected_actions=selected_actions, + validation=validation, + flow_mode=flow_mode, + routing=routing, + emulation_type=emulation_type, + iteration_count=iteration_count, + ) + + return TicketResponseEvent( + transactionId=transaction_id, + processing=Processing( + status=cms_status, + current_step=current_step or str(EmulatorGraphStep.CASE_CLOSED), + action=action, + note=state.get("processing_notes") or None, + crmProtocol=crm_protocol, + case_response=case_response, + transitions=transitions, + metadata=response_metadata or None, + ), + metadata=case_metadata, + ) diff --git a/src/compat/__init__.py b/src/compat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/compat/__pycache__/__init__.cpython-313.pyc b/src/compat/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..57ae5303f5b00cd80c0246f0c71edeb5fa989f6e GIT binary patch literal 191 zcmey&%ge<81V)ZZSs?l`h=2h`DC08=kTI1Zok5e)ZzV$!6Oi{ABy}rJKR2&LKSkd+ z*i#{(C_gJTxujS>F+DY}BtESuF*mh5zbHFCKRHuBDKR-aKP@dYIW;~xKd&scs3bEb zAE8FSxF}gaIX|}`u|z*UJ~J<~BtBlRpz;=nO>TZlX-=wL5i8I-kaLPbjE~HWjEqIh GKo$TyVKW&3 literal 0 HcmV?d00001 diff --git a/src/compat/__pycache__/framework_observer.cpython-313.pyc b/src/compat/__pycache__/framework_observer.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fe4fa6ae84c14a48c403de9c6f227d0b38e1e719 GIT binary patch literal 5887 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 literal 0 HcmV?d00001 diff --git a/src/compat/framework_observer.py b/src/compat/framework_observer.py new file mode 100644 index 0000000..76ea410 --- /dev/null +++ b/src/compat/framework_observer.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +"""Thin adapter over the real agent_framework(7) observer API. + +This module exists only to isolate the backoffice domain from direct imports +while still delegating IC/NOC/GRL/event emission to the framework. It does not +implement LLM generation telemetry; LLM generations are recorded by +agent_framework.llm.providers. +""" + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +try: + from agent_framework.observer import configure as _fw_configure + from agent_framework.observer import event as _fw_event + from agent_framework.observer import aevent as _fw_aevent + from agent_framework.observer import ic as _fw_ic + from agent_framework.observer import aic as _fw_aic + from agent_framework.observer import noc as _fw_noc + from agent_framework.observer import anoc as _fw_anoc + from agent_framework.observer import grl as _fw_grl + from agent_framework.observer import agrl as _fw_agrl +except Exception: # pragma: no cover - local dev fallback + _fw_configure = None + _fw_event = None + _fw_aevent = None + _fw_ic = None + _fw_aic = None + _fw_noc = None + _fw_anoc = None + _fw_grl = None + _fw_agrl = None + + +def configure(config: dict[str, Any] | None = None) -> None: + if _fw_configure: + try: + _fw_configure(config or {}) + except Exception: + logger.exception("framework observer configure failed") + + +def event(name: str, data: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, event_test: bool | None = None) -> dict[str, Any] | None: + if _fw_event: + try: + return _fw_event(name, data=data, metadata=metadata, event_test=event_test) + except Exception: + logger.exception("framework observer event failed name=%s", name) + return None + + +async def aevent(name: str, data: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, event_test: bool | None = None) -> dict[str, Any] | None: + if _fw_aevent: + try: + return await _fw_aevent(name, data=data, metadata=metadata, event_test=event_test) + except Exception: + logger.exception("framework observer aevent failed name=%s", name) + return None + + +def ic(code: str, data: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None) -> dict[str, Any] | None: + if _fw_ic: + try: + return _fw_ic(code, data=data, metadata=metadata) + except Exception: + logger.exception("framework observer ic failed code=%s", code) + return event(code if str(code).startswith("IC.") else f"IC.{code}", data=data, metadata=metadata) + + +async def aic(code: str, data: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None) -> dict[str, Any] | None: + if _fw_aic: + try: + return await _fw_aic(code, data=data, metadata=metadata) + except Exception: + logger.exception("framework observer aic failed code=%s", code) + return await aevent(code if str(code).startswith("IC.") else f"IC.{code}", data=data, metadata=metadata) + + +def noc(code: str, data: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None) -> dict[str, Any] | None: + if _fw_noc: + try: + return _fw_noc(code, data=data, metadata=metadata) + except Exception: + logger.exception("framework observer noc failed code=%s", code) + meta = {**dict(metadata or {}), "noc": True} + return event(code if str(code).startswith("NOC.") else f"NOC.{code}", data=data, metadata=meta) + + +async def anoc(code: str, data: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None) -> dict[str, Any] | None: + if _fw_anoc: + try: + return await _fw_anoc(code, data=data, metadata=metadata) + except Exception: + logger.exception("framework observer anoc failed code=%s", code) + meta = {**dict(metadata or {}), "noc": True} + return await aevent(code if str(code).startswith("NOC.") else f"NOC.{code}", data=data, metadata=meta) + + +def grl(code: str, data: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None) -> dict[str, Any] | None: + if _fw_grl: + try: + return _fw_grl(code, data=data, metadata=metadata) + except Exception: + logger.exception("framework observer grl failed code=%s", code) + return event(code if str(code).startswith("GRL.") else f"GRL.{code}", data=data, metadata=metadata) + + +async def agrl(code: str, data: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None) -> dict[str, Any] | None: + if _fw_agrl: + try: + return await _fw_agrl(code, data=data, metadata=metadata) + except Exception: + logger.exception("framework observer agrl failed code=%s", code) + return await aevent(code if str(code).startswith("GRL.") else f"GRL.{code}", data=data, metadata=metadata) diff --git a/src/components/.DS_Store b/src/components/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e2faab2412afec36f44ac9573bc0ff9fc2da3436 GIT binary patch literal 6148 zcmeHKu};G<5IsXG5EW8K#FSs?&Jd~~KA<d2ElsNyk&;Tlz~+D8e^{CM4E}<5=aYyV zQo%wibXVCgIp5hYPdd9MA~$%N_KA8#bcn{79HINhc${0q_SD14PUa}+h6-BHj7E)U zbC?3Az^*C4wcACtj%Y>CsNJvn9hLL`u&9=+Kp6$z8o8|Ud{R~uL|~)v)3cAa^G(sy zUqr7KbrwvQj&+YI=nkwax}|%1lztv_zIxdl9rLDZ<-KA4WmV_?lZ=?ZXZ{D^UxM#U zct1p(lgsyJL<`Tgd0M!wMMZo%W9HL=dKG>p#a!t0H6Cr@QOj@PP>YDxr;OJ(+l~TK z(kR*Pc9yqQO#xG2hZW%cAw^@%6n#M1I?%}#0GPlnhG#jWfhjwHnW7H}56p#BU`Ta( z#c&}V{#4^KMISJvlarM(kFM<WhT>#(_)`fdmj$%m6fgy11@^;hkI(;$_WOUFWN)T` zDe$ipa7jMQ2lymCTN@w8XKji$Mq}eRA5c=z>2WL%d=#IciD4||2Vkb?1HuEdKLR3y Kb*8|dD)0$Q8<SN4 literal 0 HcmV?d00001 diff --git a/src/components/__init__.py b/src/components/__init__.py new file mode 100644 index 0000000..d436bf2 --- /dev/null +++ b/src/components/__init__.py @@ -0,0 +1 @@ +"""Component Layer - Tools, prompts, memory, LLM.""" diff --git a/src/components/__pycache__/__init__.cpython-313.pyc b/src/components/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..46587ab46cbd343c8e63b42ecfd46d3a20514852 GIT binary patch literal 256 zcmYLEL5cz~5KMGY5&T2go1ha|)|)IWdlJS)(0emxG7>fEp%ZtR{fCd(FU{2t2=f6m zpjel#qMNF&W;7bIh_}zz{VdK)D<0r?(hMRwvo#~O<Kxa>@`biUr(F<fR%sGbu|Yig zi6|*<LL^pOq-rA4beVjQdEE!dQ33q3rZug%(z+mXxm+*Aig2%0@G{#)ODHI_`WNW{ zsEpiZ>VQRIlm><FM^i94K5wKaCDr2{F9D1*0U#->dN8+`pN{&2dNHys<J%(FF6X>v I@AMMI4Te!l)c^nh literal 0 HcmV?d00001 diff --git a/src/components/checkpointing/__init__.py b/src/components/checkpointing/__init__.py new file mode 100644 index 0000000..62731dc --- /dev/null +++ b/src/components/checkpointing/__init__.py @@ -0,0 +1,7 @@ +""" +Checkpointing components for persistent state management. +""" + +from src.components.checkpointing.mongodb_saver import MongoDBSaver + +__all__ = ["MongoDBSaver"] diff --git a/src/components/checkpointing/__pycache__/__init__.cpython-313.pyc b/src/components/checkpointing/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f56add8c0552e0bff04fb502bc4d5c24b7b1c93d GIT binary patch literal 347 zcmY*VF;2uV5VW13ivrifRi+F@+ykP3t3X_n2Ux~&f+4me+aRFh5xjtkcTmw$r$VBl z14Z=M5^%86?#}LNW~3db(;S3&eA+y_5B)U`KY_l%I1AtoRKS7?R_F*GB8xDn5&~_~ zrp3z5PJVDI_<3)n_ENW)8`T>vw0F2M7WcwB>3o>6^UMpZm}ad|VNvF9(WQXH+Og5C zneSKZQdpaW<)TQ338l5+lnz_Y@^YzL^1p_qiro5yx?gf+vaX}_0gTaM*d|JQQZvs; zBd;j$8xkbRQj+|WB$P@mJ*8!TJ!HGe)EAxDv+-Lnq+1W=J%JE@AOzFT48S>jS>1Tf KXSWlZh2s}ECuWHN literal 0 HcmV?d00001 diff --git a/src/components/checkpointing/__pycache__/mongodb_saver.cpython-313.pyc b/src/components/checkpointing/__pycache__/mongodb_saver.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f3167f7f011dd063b46300ca817b8fd5db732dbd GIT binary patch literal 10431 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 literal 0 HcmV?d00001 diff --git a/src/components/checkpointing/mongodb_saver.py b/src/components/checkpointing/mongodb_saver.py new file mode 100644 index 0000000..d3d8def --- /dev/null +++ b/src/components/checkpointing/mongodb_saver.py @@ -0,0 +1,365 @@ +""" +MongoDB-based checkpointer for LangGraph state persistence. + +This module provides a MongoDB implementation of the LangGraph checkpointer +interface, allowing agent state to be persisted across sessions. +""" + +from typing import Optional, Dict, Any, Iterator, Tuple +from datetime import datetime +from pymongo import MongoClient, ASCENDING +from pymongo.collection import Collection +from langgraph.checkpoint.base import BaseCheckpointSaver, Checkpoint +from src.core.logging import get_logger + +logger = get_logger(__name__) + + +class MongoDBSaver(BaseCheckpointSaver): + """ + MongoDB-based checkpoint saver for LangGraph. + + This class implements the BaseCheckpointSaver interface to store + agent state checkpoints in MongoDB, enabling: + - Persistent conversation history + - State recovery across restarts + - Multi-session support + - Time-travel debugging + + Attributes: + client: MongoDB client instance + db: MongoDB database + collection: MongoDB collection for checkpoints + + Example: + >>> from src.components.checkpointing import MongoDBSaver + >>> from legacy_reference_disabled/original_develop/src_agent_graphs import create_agent_graph_with_checkpointing + >>> + >>> # Create MongoDB checkpointer + >>> checkpointer = MongoDBSaver( + ... connection_string="mongodb://localhost:27017", + ... database_name="agent_db", + ... collection_name="checkpoints" + ... ) + >>> + >>> # Use with graph + >>> graph = create_agent_graph_with_checkpointing( + ... checkpointer=checkpointer + ... ) + >>> + >>> # Execute with thread_id + >>> config = {"configurable": {"thread_id": "user-123"}} + >>> result = graph.invoke(state, config=config) + """ + + def __init__( + self, + connection_string: str, + database_name: str = "agent_db", + collection_name: str = "checkpoints" + ): + """ + Initialize MongoDB checkpointer. + + Args: + connection_string: MongoDB connection string + database_name: Name of the database to use + collection_name: Name of the collection to store checkpoints + """ + super().__init__() + + logger.info( + "Initializing MongoDB checkpointer", + extra={ + "database": database_name, + "collection": collection_name + } + ) + + self.client = MongoClient(connection_string) + self.db = self.client[database_name] + self.collection: Collection = self.db[collection_name] + + # Create indexes for efficient querying + self._create_indexes() + + logger.info("MongoDB checkpointer initialized successfully") + + def _create_indexes(self): + """Create indexes for efficient checkpoint retrieval.""" + # Index on thread_id and checkpoint_id for fast lookups + self.collection.create_index( + [("thread_id", ASCENDING), ("checkpoint_id", ASCENDING)], + unique=True + ) + + # Index on thread_id and timestamp for chronological queries + self.collection.create_index( + [("thread_id", ASCENDING), ("timestamp", ASCENDING)] + ) + + logger.debug("MongoDB indexes created") + + def put( + self, + config: Dict[str, Any], + checkpoint: Checkpoint, + metadata: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Save a checkpoint to MongoDB. + + Args: + config: Configuration dict with thread_id + checkpoint: Checkpoint object to save + metadata: Optional metadata to store with checkpoint + + Returns: + Configuration dict for the saved checkpoint + """ + thread_id = config["configurable"]["thread_id"] + checkpoint_id = checkpoint["id"] + + logger.debug( + "Saving checkpoint", + extra={ + "thread_id": thread_id, + "checkpoint_id": checkpoint_id + } + ) + + # Prepare document + document = { + "thread_id": thread_id, + "checkpoint_id": checkpoint_id, + "checkpoint": checkpoint, + "metadata": metadata or {}, + "timestamp": datetime.utcnow() + } + + # Upsert checkpoint + self.collection.update_one( + {"thread_id": thread_id, "checkpoint_id": checkpoint_id}, + {"$set": document}, + upsert=True + ) + + logger.info( + "Checkpoint saved", + extra={ + "thread_id": thread_id, + "checkpoint_id": checkpoint_id + } + ) + + return config + + def get( + self, + config: Dict[str, Any] + ) -> Optional[Tuple[Checkpoint, Dict[str, Any]]]: + """ + Retrieve the latest checkpoint for a thread. + + Args: + config: Configuration dict with thread_id + + Returns: + Tuple of (checkpoint, metadata) if found, None otherwise + """ + thread_id = config["configurable"]["thread_id"] + + logger.debug( + "Retrieving checkpoint", + extra={"thread_id": thread_id} + ) + + # Get the most recent checkpoint for this thread + document = self.collection.find_one( + {"thread_id": thread_id}, + sort=[("timestamp", -1)] + ) + + if document is None: + logger.debug( + "No checkpoint found", + extra={"thread_id": thread_id} + ) + return None + + logger.info( + "Checkpoint retrieved", + extra={ + "thread_id": thread_id, + "checkpoint_id": document["checkpoint_id"] + } + ) + + return document["checkpoint"], document["metadata"] + + def get_by_id( + self, + config: Dict[str, Any], + checkpoint_id: str + ) -> Optional[Tuple[Checkpoint, Dict[str, Any]]]: + """ + Retrieve a specific checkpoint by ID. + + Args: + config: Configuration dict with thread_id + checkpoint_id: ID of the checkpoint to retrieve + + Returns: + Tuple of (checkpoint, metadata) if found, None otherwise + """ + thread_id = config["configurable"]["thread_id"] + + logger.debug( + "Retrieving checkpoint by ID", + extra={ + "thread_id": thread_id, + "checkpoint_id": checkpoint_id + } + ) + + document = self.collection.find_one({ + "thread_id": thread_id, + "checkpoint_id": checkpoint_id + }) + + if document is None: + logger.debug( + "Checkpoint not found", + extra={ + "thread_id": thread_id, + "checkpoint_id": checkpoint_id + } + ) + return None + + logger.info( + "Checkpoint retrieved by ID", + extra={ + "thread_id": thread_id, + "checkpoint_id": checkpoint_id + } + ) + + return document["checkpoint"], document["metadata"] + + def list( + self, + config: Dict[str, Any], + limit: Optional[int] = None + ) -> Iterator[Tuple[Checkpoint, Dict[str, Any]]]: + """ + List all checkpoints for a thread in reverse chronological order. + + Args: + config: Configuration dict with thread_id + limit: Optional limit on number of checkpoints to return + + Yields: + Tuples of (checkpoint, metadata) + """ + thread_id = config["configurable"]["thread_id"] + + logger.debug( + "Listing checkpoints", + extra={ + "thread_id": thread_id, + "limit": limit + } + ) + + query = {"thread_id": thread_id} + cursor = self.collection.find(query).sort("timestamp", -1) + + if limit: + cursor = cursor.limit(limit) + + count = 0 + for document in cursor: + yield document["checkpoint"], document["metadata"] + count += 1 + + logger.info( + "Checkpoints listed", + extra={ + "thread_id": thread_id, + "count": count + } + ) + + def delete(self, config: Dict[str, Any]) -> None: + """ + Delete all checkpoints for a thread. + + Args: + config: Configuration dict with thread_id + """ + thread_id = config["configurable"]["thread_id"] + + logger.info( + "Deleting checkpoints", + extra={"thread_id": thread_id} + ) + + result = self.collection.delete_many({"thread_id": thread_id}) + + logger.info( + "Checkpoints deleted", + extra={ + "thread_id": thread_id, + "count": result.deleted_count + } + ) + + def delete_by_id( + self, + config: Dict[str, Any], + checkpoint_id: str + ) -> None: + """ + Delete a specific checkpoint by ID. + + Args: + config: Configuration dict with thread_id + checkpoint_id: ID of the checkpoint to delete + """ + thread_id = config["configurable"]["thread_id"] + + logger.info( + "Deleting checkpoint by ID", + extra={ + "thread_id": thread_id, + "checkpoint_id": checkpoint_id + } + ) + + result = self.collection.delete_one({ + "thread_id": thread_id, + "checkpoint_id": checkpoint_id + }) + + logger.info( + "Checkpoint deleted", + extra={ + "thread_id": thread_id, + "checkpoint_id": checkpoint_id, + "deleted": result.deleted_count > 0 + } + ) + + def close(self): + """Close the MongoDB connection.""" + logger.info("Closing MongoDB connection") + self.client.close() + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close() diff --git a/src/components/clients/__pycache__/abrt_client.cpython-313.pyc b/src/components/clients/__pycache__/abrt_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..958957689d35eb05629dee990c377bcfdb7b24a4 GIT binary patch literal 10905 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 N%XEK2FqXIF{{WIjgZTge literal 0 HcmV?d00001 diff --git a/src/components/clients/__pycache__/emulator_rag_client.cpython-313.pyc b/src/components/clients/__pycache__/emulator_rag_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1bcd4ebef0796df9d7166e518f1932e274aec88b GIT binary patch literal 14219 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 literal 0 HcmV?d00001 diff --git a/src/components/clients/__pycache__/imdb_client.cpython-313.pyc b/src/components/clients/__pycache__/imdb_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b693bcd4891e429ab70a12a7e3943757ae112b8 GIT binary patch literal 10767 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 OkeX}z8Nr+#%l`lYhfSsc literal 0 HcmV?d00001 diff --git a/src/components/clients/__pycache__/portability_client.cpython-313.pyc b/src/components/clients/__pycache__/portability_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..21f30b4ba263749c29b72b730ebf407489536498 GIT binary patch literal 11187 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 literal 0 HcmV?d00001 diff --git a/src/components/clients/__pycache__/siebel_client.cpython-313.pyc b/src/components/clients/__pycache__/siebel_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..57c0288954eb222498d64e724a03d81c835821f5 GIT binary patch literal 20117 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 Mbjz<O^hw|U7o%WsKmY&$ literal 0 HcmV?d00001 diff --git a/src/components/clients/__pycache__/speech_analytics_client.cpython-313.pyc b/src/components/clients/__pycache__/speech_analytics_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b5e3d56919cda863e8bffed0e13913397a336f45 GIT binary patch literal 16122 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 literal 0 HcmV?d00001 diff --git a/src/components/clients/__pycache__/tais_kb_client.cpython-313.pyc b/src/components/clients/__pycache__/tais_kb_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f7481e57533df847d78c5179d1af29c6bce5da8c GIT binary patch literal 35370 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 literal 0 HcmV?d00001 diff --git a/src/components/clients/abrt_client.py b/src/components/clients/abrt_client.py new file mode 100644 index 0000000..365001d --- /dev/null +++ b/src/components/clients/abrt_client.py @@ -0,0 +1,233 @@ +import json +import asyncio +import random +import time +import httpx +import logging + +from httpx import Timeout +from typing import Optional, Tuple, Any +from src.core.config import settings +from src.components.clients.exceptions.abrt_exceptions import ( + AbrtClientError, + AbrtHttpError, + AbrtConnectionError, + AbrtTimeoutError, + AbrtNoContentError, + AbrtExceededRetriesError +) +from src.api.schemas.abrt_schemas import AbrtResponse +from src.utils.observer import trace_tool +from src.utils.http import traced_async_client + +logger = logging.getLogger(__name__) + + +class AbrtClient: + """ + Client for the ABRT API to retrieve access information + based on social security number and MSISDN. + """ + + def __init__( + self, + base_url: Optional[str] = None, + authorization: Optional[str] = None, + token: Optional[str] = None, + client_id: Optional[str] = None, + ): + self.timeout = Timeout(5.0, connect=float(settings.ABRT_API_TIMEOUT)) + self.base_url = base_url or settings.ABRT_API_BASE_URL + self.authorization = authorization or settings.ABRT_API_AUTHORIZATION + self.token = token or settings.ABRT_API_TOKEN + self.client_id = client_id or settings.ABRT_API_CLIENT_ID + + self._RETRYABLE_NETWORK_ERRORS = ( + AbrtTimeoutError, + AbrtConnectionError, + ) + self._RETRYABLE_STATUS_CODES = {429, 503, 504} + + def _is_retryable(self, exc: Exception) -> bool: + """ + Returns True only for transient errors that should be retried. + """ + if isinstance(exc, self._RETRYABLE_NETWORK_ERRORS): + return True + + if isinstance(exc, AbrtHttpError): + return exc.status_code in self._RETRYABLE_STATUS_CODES + + return False + + def _backoff(self, attempt: int, base: float = 1.0, cap: float = 30.0) -> float: + return random.uniform(0, min(cap, base * (2 ** attempt))) + + def _build_headers(self) -> dict[str, str]: + """Builds headers for the API request.""" + return { + "Content-Type": "application/json", + "Accept": "application/json", + "clientId": self.client_id, + "Authorization": self.authorization, + "Token": self.token + } + + def _build_body(self, social_sec_no: str, msisdn: str) -> dict: + """Builds the request body.""" + return { + "socialSecNo": social_sec_no, + "msisdn": msisdn, + "flagPos": True, + "flagPre": True + } + + def _parse_response(self, response: httpx.Response) -> AbrtResponse: + try: + response_json = response.json() + return AbrtResponse(**response_json) + except json.JSONDecodeError as exc: + logger.error("Failed to parse JSON from ABRT API.", exc_info=True) + raise AbrtClientError("Invalid JSON response from ABRT API") from exc + + @trace_tool + async def get_abrt_data( + self, + social_sec_no: str, + msisdn: str, + ) -> Tuple[AbrtResponse, dict[str, Any]]: + """Calls the ABRT API and returns (parsed_response, http_meta). + + http_meta has the shape {url, status_code, response_text, latency_ms}, + same contract used by SiebelClient/ImdbClient/TaisKbClient so that + callers can build IC payloads uniformly. + + On error, raises AbrtClientError (or subclass) carrying the same + attributes (url, status_code, response_text, latency_ms) so the + caller can read them via `getattr(exc, "<field>", None)`. + """ + start = time.perf_counter() + try: + headers = self._build_headers() + body = self._build_body(social_sec_no, msisdn) + + async with traced_async_client(timeout=self.timeout) as client: + response = await client.post( + self.base_url, + headers=headers, + json=body, + ) + latency_ms = int((time.perf_counter() - start) * 1000) + response_text = response.text + status_code = response.status_code + + if status_code == 204 or not response.content: + logger.warning( + f"ABRT API returned {status_code} with no content " + f"for msisdn={msisdn}." + ) + raise AbrtNoContentError( + f"ABRT API returned {status_code} with no usable content.", + url=self.base_url, + status_code=status_code, + response_text=response_text, + latency_ms=latency_ms, + ) + + response.raise_for_status() + + message_id = response.headers.get("Messageid") + logger.info(f"ABRT Transaction Message ID: {message_id}") + + parsed = self._parse_response(response) + http_meta = { + "url": self.base_url, + "status_code": status_code, + "response_text": response_text[:500] if response_text else "", + "latency_ms": latency_ms, + } + return parsed, http_meta + + except AbrtNoContentError: + raise + + except httpx.HTTPStatusError as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + response_text = exc.response.text if exc.response is not None else "" + logger.error( + f"HTTP error {exc.response.status_code} when calling ABRT API" + , exc_info=True) + raise AbrtHttpError( + exc.response.status_code, + f"HTTP error {exc.response.status_code} when consulting ABRT API", + url=self.base_url, + response_text=response_text[:500] if response_text else "", + latency_ms=latency_ms, + ) from exc + + except httpx.TimeoutException as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + logger.error( + f"Request to ABRT API timed out after {self.timeout.connect}s" + , exc_info=True) + raise AbrtTimeoutError( + self.base_url, + self.timeout.connect, + latency_ms=latency_ms, + ) from exc + + except httpx.RequestError as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + detail = str(exc) or repr(exc) + logger.error(f"Connection error at ABRT API: {detail}", exc_info=True) + raise AbrtConnectionError( + self.base_url, + reason=detail, + latency_ms=latency_ms, + ) from exc + + except Exception as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + detail = str(exc) or repr(exc) + logger.error(f"Unexpected error at ABRT API: {detail}", exc_info=True) + raise AbrtClientError( + f"Unexpected error at ABRT API: {detail}", + url=self.base_url, + response_text=detail[:500] if detail else "", + latency_ms=latency_ms, + ) from exc + + async def get_abrt_data_with_retry( + self, + social_sec_no: str, + msisdn: str, + max_retries: int = 3, + ) -> Tuple[AbrtResponse, dict[str, Any]]: + """Returns (parsed_response, http_meta) following the same contract as + SiebelClient.open_service_request_with_retry. The http_meta includes + a "retry_count" field with the number of retries performed. + """ + for attempt in range(max_retries + 1): + try: + response, http_meta = await self.get_abrt_data(social_sec_no, msisdn) + http_meta["retry_count"] = attempt + return response, http_meta + + except AbrtClientError as err: + if not self._is_retryable(err): + raise + + if attempt == max_retries: + raise AbrtExceededRetriesError( + self.base_url, + max_retries, + err, + latency_ms=getattr(err, "latency_ms", None), + ) + + wait = self._backoff(attempt) + logger.warning( + f"Transient error (attempt {attempt + 1}/{max_retries}), " + f"retrying ABRT API call. Last error: {err}" + ) + await asyncio.sleep(wait) \ No newline at end of file diff --git a/src/components/clients/emulator_rag_client.py b/src/components/clients/emulator_rag_client.py new file mode 100644 index 0000000..6dfbf27 --- /dev/null +++ b/src/components/clients/emulator_rag_client.py @@ -0,0 +1,259 @@ +""" +DB/embedding client for the emulator RAG sources. + +Generates the query embedding via OCI GenAI (Cohere multilingual) and runs a +vector-similarity search against the emulator RAG tables in the same Oracle +Autonomous Database used by TAIS. Mirrors the TAIS KB client pattern, but is +a trimmed-down version (no query pre/postprocessing, no segment filters). + +The two sources have different schemas, so the id/text/metadata columns are +mapped per source. The embedding column is always `EMBEDDING` (VECTOR). +""" + +import asyncio +import json +import logging +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +import oci +import oci.exceptions +import oci.generative_ai_inference.models +import oci.retry +import oracledb + +from src.components.clients.exceptions.emulator_rag_exceptions import EmulatorRagClientError +from src.core.config import settings + +logger = logging.getLogger(__name__) + +# Return CLOBs as native strings instead of LOB handles to keep the search code simple. +oracledb.defaults.fetch_lobs = False + + +class EmulatorRagSource(str, Enum): + """Supported emulator RAG sources.""" + templates = "templates" + anatel_resposta = "anatel_resposta" + + +@dataclass(frozen=True) +class _SourceMapping: + """Maps an emulator RAG source to its table and relevant columns.""" + table_setting: str + id_col: str + text_col: str + embedding_col: str = "EMBEDDING" + metadata_cols: tuple[str, ...] = field(default_factory=tuple) + + +_SOURCE_MAPPINGS: dict[EmulatorRagSource, _SourceMapping] = { + EmulatorRagSource.templates: _SourceMapping( + table_setting="EMULATOR_RAG_TEMPLATES_CHUNKS", + id_col="ID", + text_col="EMBEDDING_TEXT", + metadata_cols=( + "ITEM", + "QUANDO_USAR", + "INFORMACOES_OBRIGATORIAS", + "SUGESTAO_PARA_COMPOR_RESPOSTA", + "MENU", + ), + ), + EmulatorRagSource.anatel_resposta: _SourceMapping( + table_setting="EMULATOR_RAG_ANATEL_NOTAS_RESPOSTA_CHUNKS", + id_col="ID_CHUNK", + text_col="CHUNK_RESPOSTA", + metadata_cols=("ID", "NOTA"), + ), +} + + +class EmulatorRagClient: + """Async client for the emulator RAG tables (Oracle ADB + OCI embeddings).""" + + _embed_client: oci.generative_ai_inference.GenerativeAiInferenceClient | None = None + + @classmethod + def _get_embed_client(cls) -> oci.generative_ai_inference.GenerativeAiInferenceClient: + if cls._embed_client is not None: + return cls._embed_client + + if not settings.EMULATOR_RAG_OCI_GENAI_ENDPOINT or not settings.EMULATOR_RAG_COMPARTMENT_ID: + raise EmulatorRagClientError( + "Emulator RAG GenAI not configured " + "(EMULATOR_RAG_OCI_GENAI_ENDPOINT, EMULATOR_RAG_COMPARTMENT_ID)." + ) + + import os + from agent_framework.config.settings import settings as fw_settings + + oci_config = oci.config.from_file( + os.path.expanduser(getattr(fw_settings, "OCI_CONFIG_FILE", "~/.oci/config")), + getattr(fw_settings, "OCI_PROFILE", None) or settings.OCI_CONFIG_PROFILE, + ) + if getattr(fw_settings, "OCI_REGION", None): + oci_config["region"] = fw_settings.OCI_REGION + + cls._embed_client = oci.generative_ai_inference.GenerativeAiInferenceClient( + config=oci_config, + service_endpoint=settings.EMULATOR_RAG_OCI_GENAI_ENDPOINT, + retry_strategy=oci.retry.NoneRetryStrategy(), + timeout=settings.TAIS_DB_TIMEOUT, + ) + return cls._embed_client + + def _embed_sync(self, text: str) -> list[float]: + client = self._get_embed_client() + embed_detail = oci.generative_ai_inference.models.EmbedTextDetails() + embed_detail.serving_mode = oci.generative_ai_inference.models.OnDemandServingMode( + model_id=settings.EMULATOR_RAG_EMBED_MODEL_ID + ) + embed_detail.inputs = [text] + embed_detail.truncate = "NONE" + embed_detail.compartment_id = settings.EMULATOR_RAG_COMPARTMENT_ID + embed_detail.input_type = "SEARCH_QUERY" + + endpoint = settings.EMULATOR_RAG_OCI_GENAI_ENDPOINT + start = time.perf_counter() + try: + response = client.embed_text(embed_detail) + except oci.exceptions.ServiceError as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + raise EmulatorRagClientError( + f"OCI embedding service error: {exc}", + status_code=exc.status, + url=endpoint, + response_text=str(getattr(exc, "message", exc)), + latency_ms=latency_ms, + ) from exc + except (oci.exceptions.RequestException, oci.exceptions.ConnectTimeout) as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + raise EmulatorRagClientError( + f"OCI embedding request error: {exc}", + url=endpoint, + response_text=str(exc), + latency_ms=latency_ms, + ) from exc + + return response.data._embeddings[0] + + async def _embed(self, text: str) -> list[float]: + # OCI Python SDK has no async client; isolate the blocking call. + return await asyncio.to_thread(self._embed_sync, text) + + @staticmethod + def _fill_sql_with_bind_params(sql: str, bind_params: dict[str, object]) -> str: + """Replace bind parameters in SQL with their values (for debugging). Vectors are masked.""" + filled_sql = sql + for key, value in bind_params.items(): + if key == "query_embedding": + filled_sql = filled_sql.replace(f":{key}", "[VECTOR]") + elif isinstance(value, str): + filled_sql = filled_sql.replace(f":{key}", f"'{value.replace(chr(39), chr(39) * 2)}'") + elif isinstance(value, (int, float)): + filled_sql = filled_sql.replace(f":{key}", str(value)) + elif value is None: + filled_sql = filled_sql.replace(f":{key}", "NULL") + else: + filled_sql = filled_sql.replace(f":{key}", f"'{str(value)}'") + return filled_sql + + async def search( + self, + source: EmulatorRagSource, + query: str, + nota_min: int | None = 4, + nota_max: int | None = None, + top_k: int | None = None, + ) -> dict[str, Any]: + """Embed `query` and return the closest chunks from the given source. + + `nota_min`/`nota_max` only apply to anatel_resposta and bound the + note range (inclusive): pass only `nota_min` for the high-score + bucket, only `nota_max` for the low-score bucket, or both for a + closed range. Either may be `None` to leave that side unbounded. + Returns `{results: [{id, content, distance, metadata}], top_k, sql}`. + """ + if not query or not query.strip(): + raise ValueError("query must be a non-empty string") + if not isinstance(source, EmulatorRagSource): + raise ValueError(f"Invalid source: {source!r}. Valid: {[s.value for s in EmulatorRagSource]}") + + mapping = _SOURCE_MAPPINGS[source] + table = getattr(settings, mapping.table_setting) + effective_top_k = top_k or settings.EMULATOR_RAG_TOP_K + + embedding = await self._embed(query) + embedding_str = json.dumps(embedding) + + bind_params: dict[str, object] = { + "query_embedding": embedding_str, + "fetch_limit": effective_top_k, + } + + select_cols = [mapping.id_col, mapping.text_col, *mapping.metadata_cols] + select_clause = ",\n ".join(select_cols) + where_clauses = "" + if source is EmulatorRagSource.anatel_resposta: + # `nota` is a validated int (settings/Query, ge=0 le=5), interpolated + # directly like before — never a user-supplied string. + note_filters = [] + if nota_min is not None: + note_filters.append(f"nota >= {int(nota_min)}") + if nota_max is not None: + note_filters.append(f"nota <= {int(nota_max)}") + if note_filters: + where_clauses = "WHERE " + " AND ".join(note_filters) + sql = f""" + SELECT + {select_clause}, + VECTOR_DISTANCE({mapping.embedding_col}, TO_VECTOR(:query_embedding), COSINE) AS distance + FROM {table} + {where_clauses} + ORDER BY distance ASC + FETCH FIRST :fetch_limit ROWS ONLY + """ + + start = time.perf_counter() + try: + async with oracledb.connect_async( + user=settings.MONGODB_DB_USER, + password=settings.MONGODB_DB_PASSWORD, + dsn=settings.TAIS_DB_DSN, + tcp_connect_timeout=settings.TAIS_DB_TIMEOUT, + ) as conn: + async with conn.cursor() as cur: + await cur.execute(sql, bind_params) + rows = await cur.fetchall() + cols = [c[0].lower() for c in cur.description] + except oracledb.Error as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + raise EmulatorRagClientError( + f"Emulator RAG DB error: {exc}", + url=settings.TAIS_DB_DSN, + response_text=str(exc), + latency_ms=latency_ms, + ) from exc + + id_key = mapping.id_col.lower() + text_key = mapping.text_col.lower() + metadata_keys = [c.lower() for c in mapping.metadata_cols] + + results: list[dict[str, Any]] = [] + for row in rows: + record = dict(zip(cols, row)) + results.append({ + "id": str(record.get(id_key)) if record.get(id_key) is not None else "", + "content": record.get(text_key) or "", + "distance": float(record.get("distance", 0.0)), + "metadata": {k: record.get(k) for k in metadata_keys} or None, + }) + + return { + "results": results, + "top_k": effective_top_k, + "sql": self._fill_sql_with_bind_params(sql, bind_params), + } diff --git a/src/components/clients/exceptions/__pycache__/abrt_exceptions.cpython-313.pyc b/src/components/clients/exceptions/__pycache__/abrt_exceptions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..196b9b71c37523250c5c035333471b96d1685df4 GIT binary patch literal 4689 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 literal 0 HcmV?d00001 diff --git a/src/components/clients/exceptions/__pycache__/emulator_rag_exceptions.cpython-313.pyc b/src/components/clients/exceptions/__pycache__/emulator_rag_exceptions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c2b8c539835f088a905d59f7a75e99aa10fbca2e GIT binary patch literal 1811 zcmZ`(TW=Fb6rT0^vR>Z^xzvpz3=rD53USpYR6;Ex4N*(ghR7RBDTIzTyJNG?de@y< z6OxyrszO9+rRD+2KLOtQQxY{2)~XMc`qVd5gW$1ecI_mJU~Hc`*YUZ0GrdekMX)}Z zzfj+o5&B06gB9yDy>Vckpgg*OG&GJhX${SRuUJf~MT)W!3L(w*h2A4zet@_&@X`>A zXcCjP2#Zc2Evw1FCpsf*v9*{MUyEz<<pe@n0;G^r4!Pu^Rn_F#bOp^yT525Cm3p)u zsY@)OscW&>NT5%LdZuU@maL#JB&JTHV$L61Y_x3Mb6HuhE!Y-yyhX-b_OAp<>I*>2 zy2Yu9H}6pgd-o`&eSch@{|ei#yU}Xms&3gFPu=(uQxQB3PV3Y(EvGh67_L*bYPex> z4%*_3s?K{Jv({T4<yTb<i+j4);>2)GdKKSldY0?xHvW2LWw~#{!i1-)%x&OX3pY>x z2Zuw&IW1-%i?5WwEm6m8x|ZWX`nrcVb&d_)wy7EVGs>H;!zuCT1MgVmK#$j5b1QU( zK2gWmA`O1b&uvnsi)?Y<j4cQ1v~6e`RBiGZb*U)(GUVlF28RZJ=Bq~s`iTbRT(40- zb*vFgAdSTZ0Sq8N&ZyU7j?o7h68qrSTLIyk^aS03JzOjC3c3qNRHQjEPFNVNVmlJ@ z2(5l>4^Bd>*X`(`e%T&C9M#Y>X{i|ZW4r~0`U)YIV|j$IBzT|5ubA+ooZ3~U2-{m6 z{3IcUt#eKY7X^uVVf@hlrPOe|l3DsryNQ>XTc?J{OVA9*BUPq1=%&jyh-+A-b=}x- zt5wUO02JpwWu9faM?NLajFRCtV4x7oOGW@_UZM{S8XyYTK70w&Yvf2j)7)a>g8Jv) zejjEAb*I3IPXT%J5WPH&lG&ZazLIahcc7f_6h1gm@}1$aUzN_;j}Daa&iLeSxgPi; zqu+nQBhhEk_g)>yiHX5w6%(5Si&&0SLFL1_ggQY*0%GZ>;c0k>PZnvBij+m4MkfIQ zaM|Uh?f9MGLTyLx;-btlaDtzJS$M2JP*CQCGP<sF%1!~xMW!$#HUv8x6~>8owkSAA zJc|4?Akd+|aw9vNJ3sxI!;hn#RPK4I0N>7Np?%}|nd$vA(|d)>Ps@KqpWfdazOt9T z(#Z`!PO)K#Ek*;xhuA+tQd*c&0)-C1f)HONq~V$^TkvT@er)M>-;yJwYBBEF7GS{z zGZbpN1~4+D>C5mS1QQQy8WmlDYitnx@LdAeOdqcXwQWL*QrHqP%<Ex8M0c1VLdSmw z^5zkG5lO_9ZXR9yu$#!lE_GE@IM<n+>YTsyUk>EIheo<Fp^u}n_uI3(w+CL?q2FhB zZg*3{mPYwPdu4aKn-NkLjpo~vyWez0eD@Smv#*DMdx3;F5si(shhHPuUI`L3Fi2{t t81a*f1Mml`XDBp_ar(;VVe_uBi-Qt`zAH<T^fGc*8fkz33c(hH{0}}$%p(8* literal 0 HcmV?d00001 diff --git a/src/components/clients/exceptions/__pycache__/imdb_exceptions.cpython-313.pyc b/src/components/clients/exceptions/__pycache__/imdb_exceptions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c03987cea6773580d02cf403659b38cc4667ecbd GIT binary patch literal 3994 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 QH<D6(?f#bpGO^760j*bF&;S4c literal 0 HcmV?d00001 diff --git a/src/components/clients/exceptions/__pycache__/portability_exceptions.cpython-313.pyc b/src/components/clients/exceptions/__pycache__/portability_exceptions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c8feb2563fe57809df0d14e9e896c29cce40bd36 GIT binary patch literal 4815 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 literal 0 HcmV?d00001 diff --git a/src/components/clients/exceptions/__pycache__/siebel_exceptions.cpython-313.pyc b/src/components/clients/exceptions/__pycache__/siebel_exceptions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cf162c1fd3d2f485c3d731f24a978b9be4211ae4 GIT binary patch literal 4635 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 literal 0 HcmV?d00001 diff --git a/src/components/clients/exceptions/__pycache__/speech_exceptions.cpython-313.pyc b/src/components/clients/exceptions/__pycache__/speech_exceptions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..63b7ee290f23f3f23c69f3581628818f94935eaa GIT binary patch literal 2054 zcmaJ>-)|H}9H0GhciU~RC$>dwfsBBJBjwI0pdm_VkVF!NrrV1J3hQ)tuD8eB?lCh< zITDozV<74yNncESgg1EePtdm`l5pAh=96!&mB5qnJG*ynMReMm`Of#(e!rjZcfK3N zqK#m^J+rU*y@}9o>M~flKC!VEh`Y!^9PL8;kfTi^M_<&A8JV2x%SPF}`JR0`7BXsK zdc&h_8APGL^@wBHpx9|s>NW68NLWV(p1?D+XR*fu8p?|84Zf$&(f(!QD<H1GR~Hf2 z;LEzBal_HM>5Mu?`e#-QC%2e$@{4)LI8s2!DZnkGtW9cYQ))ZL(UBTDsyV}xsA)CL zrry+e!Lb)}NA;9HlJU9a6!}mMeV}ps04kS~9a;FN(|FHt9`WDv5FPnTL&%;ZfuKHK zY0&V0QYDPxItf??2?;xxQzD|!K15;wC<IW5f(%pTej3Z5MWa|w<2a-@+SHTqiI3*z zXR(k(#$p;%=~c=V+nxt>MT)LVLmqgI7C3Xm$fp=QdJQTdsz7WN1<MErtu~`dQAKPo zbK=o@%#QdJNI#3C9kIacoWLwv#sser9tI&$p^v=?q77r3W(x-GhP@KV1`cD$F$)EQ z6M}<KNJ@Mh)iGI$I2bLbHgy)QK;$!0@?a^JRGhFetSmFvgV0al^JxPSwmoV1vQI0i zZp_&>o!Z>T;Fw4VQ$W^?Q9o1TRJ5TDsVnIXxy@j}$4iml$y6DUH1s-dOZ-FcT1<t+ zeIaCoTLEKGZ~(y<75iM-NDK%jDGpqwpGxd4cS)f|g&@l`8Qzv5S8t1}F5=L+D9Ljw zV;*|_Cxz5G_-)L?<)n5OU4k=P^Z6RO3@&QgG3Y_HFj~M&&uD}e-eLn!XyFuthn=m3 zSvGjoXc^tt&Xw~?PQ-1>6U%jjFp#dxhd}p*_{xRE6qMDurDQKrNQPY3V?+qo6)GWB zP>gRSUsPM6toqeYoU?eAM@{HtQ3WudN9&xl=t{(|x{()Dmxy;Ys@DULLI;G`DYT0p zZRu16_o`mhf^vebsAjW6RJUeLb?^!{r%$Eb;cA>DlmB|Zl|iyQK`su$_WTz5)hga8 zJhFDJzV_I9qc?T%v9+r=w)0!7x99L<YqB?a;JeZWXd<r<xuTJRRzbbdgzbeQ!BkO2 zItCK8Lam18HcOH5)PAT>l2>7d{fEvdOx&6_irz5~K+$0;8Ru^1FQpUVrhXZh4PFE< zNde%OykA3LmK4)mB7*W)fD|fHC=1^KC!bKl*I$NB>SK>Gco#MWO=<kj%AGHNDdF2@ zZ@Bbicnbb|6H}`nKG{9}X!mq?>d3vh2j;!&-Ld1{k>kD6*zI9H2DW80?eDD9xB6y8 ziH23rh0p}9o7k?~iu{-<dc<|F#f0@GCD*M7Tu2r`<s%S7j(+3;VL+IP5ri_Wa+c17 z0stnM=aS9w0;<J+cY2gD*DY%q8mf+`GAtA@Ty5$}if>?h{xw?Hi#cm;Z!Y&{_u`c` zgg)11bT}6E&-6bKaDOn?)bU{dkLJU}DfyGrbw2(1VoG---i<qtqE31PyYu>-GOy_u z^mTQ+05>oP7S#j&nWEQI#;BPaUmbgf;CQOG6v;F?m^XYk1eXk*8MJxIzmGB<RoBFN kKZEil-w#)nq)=_XW@wtWuIrllr#+>OuMRy$aHMvB1I-B|cmMzZ literal 0 HcmV?d00001 diff --git a/src/components/clients/exceptions/__pycache__/tais_kb_exceptions.cpython-313.pyc b/src/components/clients/exceptions/__pycache__/tais_kb_exceptions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c8520f3cea5609d4cef0d9a0f19d82ca4a43a73e GIT binary patch literal 1798 zcmZ`3O>YxNbk^&S_4<pDgc=*@0HKYm5LazNCDbBPKq9maQP!3s2pz3=$7Y@Nt~;|P zBo|N*h)Au}9FY7I;MSi4A`;fBhe|#5W@-=|`)1cpqLe<{Z{F8Dzt0(DGAaW2V&P2l zrHs&Df*C<<NDK-<JVAMM25G2(G-(aZgRKB2H6kV12$>LPhs@v+5I@4bHL%hUi)a#) zwFrxzLRwangHLo;)?#ZhExs1l<nsxHv;=S=rR-73J*}$AbLlFYleAO;HI-(x8EHx^ zp{Z-Jxkw;ShjON98J4V~ZzQHpp;FEttLPU0u6D(?sN-E@%w>N|5TAYn@CvA?i8t<2 z2YYuZt}HBSc-?U~Y-%<ruIZd&+jZAFZCuwao8#%L-(o6!%wXE8QPZ@X#z<hePTgwY zmc=<pOS7uF;Caldbv(*1su+NKy4T^va7}s<-)wu9>*zMVUa2e(RamI-be*{^eDliU z;s4;^RQTyI`@sF`%J*gJm~Gc`Jjg=#@P^K@q1%u|XwN8byAG$sqxZc7H%D?@bIr}r z)Yd)f7@MTU57@bF%5)Je9;&hBK%TY@)q|`}KC3R3WM783{LIK{@Mpfd@6b=QDCc^E z`l$njU}Dx-Tp*YN<i{EHI?OSKV}`^z`VA_;T#}xk+i-?ZiC57ba8Z@!MOOhZTE%uG zq!C*E+#Y#CtC#KQo_yIJ4LNF{XVOwB?#Fls67>~AEXVQ)VM(w)6kjRfM>)0YOcA;d z82Cv-3|r@%5H1oD{lfU6|DfD*ys}yTL0iPj%xzM`<7Fs@<B>YkTXe%^>%=vza!ohZ z-Fn?JC=7~ok223P-F=%fXGYm@ThLIr%gaVE*St*c8#I_GUiM(B$$E|Ki)P!KOl+rL z_|G!Tj<QXGo=*dM{}8=7ijvvw#0w?g{b*M?-79{wtK@s*$9_?ICqLU&3cbSAuekx( zB4b!}K_gLJQSCt!(4juTl8QdfgGPWORnf{`H$s`fA^-vUQFs#m$0m!kNL9+BFQZe? zcUW?HX)As^Sfj1T9bA%G20Zu)=zzzDZ3JOX2%}=B*%2VQh!i@*#^7X=LV5f{)d_rv zcaVPt2#WJZZen|5`@zc`ejM$ka<5WF`1U4?-K(!o%)B@;^SpTe>B{fXr}v(ZUwEFr z(94ZKPO)*gTZ#rF9uE8d=%j@zCC1KyIUvMW32C`z#};&&ke@obJ=Ej~sauSDwgt_1 zK@B;Yt^tG$Vfr$>070L_bS6asU~dhg?>$6dni&qLL2a9mk`z`$^zm|73sD#*kdX18 z0lj~OUPlr!rJqM<KkX+nv2%SD6+iAxP4`Zp`zHtdpJNmKn2^WOvE$vjom(TT?AUK} z+qe2Dp-ZEDv0K^M>SqL(MU(mN)Xt5*xZgj5)a<)4pk5=vo{GjMy5sK<9B%~*3K#^n uREqe?YolooM9)yD7M=8!ufyV9VrNGw2zg(YB<W4$q%_e@zC~~ZSN;X%&c&So literal 0 HcmV?d00001 diff --git a/src/components/clients/exceptions/abrt_exceptions.py b/src/components/clients/exceptions/abrt_exceptions.py new file mode 100644 index 0000000..e11e1af --- /dev/null +++ b/src/components/clients/exceptions/abrt_exceptions.py @@ -0,0 +1,106 @@ +class AbrtClientError(Exception): + """Base for all ABRT Client Errors. + + Carries optional http_meta attributes so downstream callers can build + consistent IC payloads via `getattr(exc, "<field>", None) or fallback`, + same pattern used by SiebelClientError/ImdbClientError. + """ + def __init__( + self, + *args, + url: str | None = None, + status_code: int | None = None, + response_text: str | None = None, + latency_ms: int | None = None, + **kwargs, + ): + super().__init__(*args) + self.url = url + self.status_code = status_code + self.response_text = response_text + self.latency_ms = latency_ms + + +class AbrtNoContentError(AbrtClientError): + """API got a response but with no content (ex: 204).""" + pass + + +class AbrtHttpError(AbrtClientError): + """API responded with an error status (4xx, 5xx).""" + def __init__( + self, + status_code: int, + message: str, + *, + url: str | None = None, + response_text: str | None = None, + latency_ms: int | None = None, + ): + super().__init__( + message, + url=url, + status_code=status_code, + response_text=response_text, + latency_ms=latency_ms, + ) + + +class AbrtTimeoutError(AbrtClientError): + """Request timed out before the server responded.""" + def __init__( + self, + url: str, + timeout: float, + *, + latency_ms: int | None = None, + ): + self.timeout = timeout + super().__init__( + f"Request to {url!r} timed out after {timeout}s", + url=url, + latency_ms=latency_ms, + ) + + +class AbrtConnectionError(AbrtClientError): + """Could not establish a connection to the API.""" + def __init__( + self, + url: str, + reason: str = "", + *, + latency_ms: int | None = None, + ): + self.reason = reason + msg = f"Connection to {url!r} failed" + if reason: + msg += f": {reason}" + super().__init__( + msg, + url=url, + response_text=reason or None, + latency_ms=latency_ms, + ) + + +class AbrtExceededRetriesError(AbrtClientError): + """All retry attempts were exhausted for a transient error.""" + def __init__( + self, + url: str, + attempts: int, + last_error: AbrtClientError, + *, + latency_ms: int | None = None, + ): + self.attempts = attempts + self.last_error = last_error + super().__init__( + f"Exceeded {attempts} retry attempt(s) for {url!r} " + f"— last error: {last_error}", + url=url, + status_code=getattr(last_error, "status_code", None), + response_text=getattr(last_error, "response_text", None) or str(last_error), + latency_ms=latency_ms if latency_ms is not None else getattr(last_error, "latency_ms", None), + ) \ No newline at end of file diff --git a/src/components/clients/exceptions/emulator_rag_exceptions.py b/src/components/clients/exceptions/emulator_rag_exceptions.py new file mode 100644 index 0000000..0e57e0e --- /dev/null +++ b/src/components/clients/exceptions/emulator_rag_exceptions.py @@ -0,0 +1,30 @@ +class EmulatorRagClientError(Exception): + """Raised when the emulator RAG lookup fails (DB error, embedding error, config missing). + + Attributes: + status_code: Optional HTTP status code (from OCI embedding errors). + url: Optional URL/endpoint that was called. + response_text: Optional response body. + latency_ms: Optional operation latency in milliseconds. + """ + + def __init__( + self, + message: str, + status_code: int | None = None, + *, + url: str | None = None, + response_text: str | None = None, + latency_ms: int | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.url = url + self.response_text = response_text + self.latency_ms = latency_ms + + def __str__(self) -> str: + base = super().__str__() + if self.status_code: + return f"[HTTP {self.status_code}] {base}" + return base diff --git a/src/components/clients/exceptions/imdb_exceptions.py b/src/components/clients/exceptions/imdb_exceptions.py new file mode 100644 index 0000000..e38bddf --- /dev/null +++ b/src/components/clients/exceptions/imdb_exceptions.py @@ -0,0 +1,45 @@ +class ImdbClientError(Exception): + """Base for all IMDB Client Errors.""" + def __init__(self, message: str, *, url: str | None = None, latency_ms: int | None = None) -> None: + super().__init__(message) + self.url = url + self.latency_ms = latency_ms + +class ImdbHttpError(ImdbClientError): + """API responded with an error status (4xx, 5xx).""" + def __init__(self, status_code: int, message: str, *, url: str | None = None, response_text: str | None = None, latency_ms: int | None = None): + self.status_code = status_code + self.response_text = response_text + super().__init__(message, url=url, latency_ms=latency_ms) + +class ImdbTimeoutError(ImdbClientError): + """Request timed out before the server responded.""" + def __init__(self, url: str, timeout: float, *, latency_ms: int | None = None): + self.url = url + self.timeout = timeout + super().__init__(f"Request to {url!r} timed out after {timeout}s", url=url, latency_ms=latency_ms) + +class ImdbConnectionError(ImdbClientError): + """Could not establish a connection to the API.""" + def __init__(self, url: str, reason: str = "", *, latency_ms: int | None = None): + self.url = url + self.reason = reason + msg = f"Connection to {url!r} failed" + if reason: + msg += f": {reason}" + super().__init__(msg, url=url, latency_ms=latency_ms) + +class ImdbExceededRetriesError(ImdbClientError): + """All retry attempts were exhausted for a transient error.""" + def __init__(self, url: str, attempts: int, last_error: ImdbClientError): + self.url = url + self.attempts = attempts + self.last_error = last_error + # Propagate latency_ms from the last error if available + self.latency_ms = getattr(last_error, 'latency_ms', None) + super().__init__( + f"Exceeded {attempts} retry attempt(s) for {url!r} " + f"— last error: {last_error}", + url=url, + latency_ms=self.latency_ms + ) diff --git a/src/components/clients/exceptions/portability_exceptions.py b/src/components/clients/exceptions/portability_exceptions.py new file mode 100644 index 0000000..5d6fb1f --- /dev/null +++ b/src/components/clients/exceptions/portability_exceptions.py @@ -0,0 +1,105 @@ +class PortabilityClientError(Exception): + """Base for all Portability Client Errors. + + Carries optional http_meta attributes so downstream callers can build + consistent IC payloads via `getattr(exc, "<field>", None) or fallback`, + same pattern used by SiebelClientError/ImdbClientError/AbrtClientError. + """ + def __init__( + self, + *args, + url: str | None = None, + status_code: int | None = None, + response_text: str | None = None, + latency_ms: int | None = None, + ): + super().__init__(*args) + self.url = url + self.status_code = status_code + self.response_text = response_text + self.latency_ms = latency_ms + + +class PortabilityNoContentError(PortabilityClientError): + """API got a response but with no content (ex: 204).""" + pass + + +class PortabilityHttpError(PortabilityClientError): + """API responded with an error status (4xx, 5xx).""" + def __init__( + self, + status_code: int, + message: str, + *, + url: str | None = None, + response_text: str | None = None, + latency_ms: int | None = None, + ): + super().__init__( + message, + url=url, + status_code=status_code, + response_text=response_text, + latency_ms=latency_ms, + ) + + +class PortabilityTimeoutError(PortabilityClientError): + """Request timed out before the server responded.""" + def __init__( + self, + url: str, + timeout: float, + *, + latency_ms: int | None = None, + ): + self.timeout = timeout + super().__init__( + f"Request to {url!r} timed out after {timeout}s", + url=url, + latency_ms=latency_ms, + ) + + +class PortabilityConnectionError(PortabilityClientError): + """Could not establish a connection to the API.""" + def __init__( + self, + url: str, + reason: str = "", + *, + latency_ms: int | None = None, + ): + self.reason = reason + msg = f"Connection to {url!r} failed" + if reason: + msg += f": {reason}" + super().__init__( + msg, + url=url, + response_text=reason or None, + latency_ms=latency_ms, + ) + + +class PortabilityExceededRetriesError(PortabilityClientError): + """All retry attempts were exhausted for a transient error.""" + def __init__( + self, + url: str, + attempts: int, + last_error: PortabilityClientError, + *, + latency_ms: int | None = None, + ): + self.attempts = attempts + self.last_error = last_error + last_error_text = getattr(last_error, "response_text", None) or str(last_error) + super().__init__( + f"Exceeded {attempts} retry attempt(s) for {url!r} — last error: {last_error}", + url=url, + status_code=getattr(last_error, "status_code", None), + response_text=last_error_text, + latency_ms=latency_ms if latency_ms is not None else getattr(last_error, "latency_ms", None), + ) diff --git a/src/components/clients/exceptions/siebel_exceptions.py b/src/components/clients/exceptions/siebel_exceptions.py new file mode 100644 index 0000000..33b5cae --- /dev/null +++ b/src/components/clients/exceptions/siebel_exceptions.py @@ -0,0 +1,80 @@ +""" +Exceptions for the SiebelClient. + +Hierarchy: + SiebelClientError (base) + ├── SiebelHttpError – Non-2xx HTTP response from the Siebel API + ├── SiebelConnectionError – Could not establish a connection to the Siebel API + └── SiebelTimeoutError – Request to the Siebel API timed out +""" + + +class SiebelClientError(Exception): + """Base exception for all Siebel client errors.""" + def __init__(self, message: str, *, url: str | None = None, latency_ms: int | None = None) -> None: + super().__init__(message) + self.url = url + self.latency_ms = latency_ms + + +class SiebelHttpError(SiebelClientError): + """ + Raised when the Siebel API returns a non-2xx HTTP status code. + + Attributes: + status_code: HTTP status code returned by the API. + response_text: Raw response body returned by the API. + url: The URL that was called. + latency_ms: The request latency in milliseconds. + """ + + def __init__(self, status_code: int, response_text: str, *, url: str | None = None, latency_ms: int | None = None) -> None: + self.status_code = status_code + self.response_text = response_text + super().__init__( + f"Siebel API returned {status_code}: {response_text}", + url=url, + latency_ms=latency_ms + ) + + +class SiebelConnectionError(SiebelClientError): + """ + Raised when a connection to the Siebel API cannot be established. + + This typically wraps lower-level network errors such as DNS failures + or refused connections. + + Attributes: + url: The URL that was being connected to. + latency_ms: The request latency in milliseconds. + """ + def __init__(self, message: str, *, url: str | None = None, latency_ms: int | None = None) -> None: + super().__init__(message, url=url, latency_ms=latency_ms) + + +class SiebelTimeoutError(SiebelClientError): + """ + Raised when a request to the Siebel API exceeds the configured timeout. + + Attributes: + url: The URL that was being called. + latency_ms: The request latency in milliseconds. + """ + def __init__(self, message: str, *, url: str | None = None, latency_ms: int | None = None) -> None: + super().__init__(message, url=url, latency_ms=latency_ms) + +class SiebelExceededRetriesError(SiebelClientError): + """All retry attempts were exhausted for a transient error.""" + def __init__(self, url: str, attempts: int, last_error: SiebelClientError): + self.url = url + self.attempts = attempts + self.last_error = last_error + # Propagate latency_ms from the last error if available + self.latency_ms = getattr(last_error, 'latency_ms', None) + super().__init__( + f"Exceeded {attempts} retry attempt(s) for {url!r} " + f"— last error: {last_error}", + url=url, + latency_ms=self.latency_ms + ) diff --git a/src/components/clients/exceptions/speech_exceptions.py b/src/components/clients/exceptions/speech_exceptions.py new file mode 100644 index 0000000..a09cad7 --- /dev/null +++ b/src/components/clients/exceptions/speech_exceptions.py @@ -0,0 +1,33 @@ +""" +Custom exceptions for the Speech Analytics API client. +""" + + +class SpeechClientError(Exception): + """ + Raised when the Speech Analytics API call fails for any reason + (authentication error, timeout, unexpected HTTP status, etc.). + + The speech_enrichment_node catches this exception to implement the + graceful-degradation policy: log a warning and continue the graph + with null speech data instead of aborting the flow. + + Attributes: + status_code: Optional HTTP status code. + url: Optional URL that was called. + response_text: Optional response body. + latency_ms: Optional request latency in milliseconds. + """ + + def __init__(self, message: str, status_code: int | None = None, *, url: str | None = None, response_text: str | None = None, latency_ms: int | None = None) -> None: + super().__init__(message) + self.status_code = status_code + self.url = url + self.response_text = response_text + self.latency_ms = latency_ms + + def __str__(self) -> str: + base = super().__str__() + if self.status_code: + return f"[HTTP {self.status_code}] {base}" + return base diff --git a/src/components/clients/exceptions/tais_kb_exceptions.py b/src/components/clients/exceptions/tais_kb_exceptions.py new file mode 100644 index 0000000..c0a7a3d --- /dev/null +++ b/src/components/clients/exceptions/tais_kb_exceptions.py @@ -0,0 +1,30 @@ +class TaisKbClientError(Exception): + """Raised when the TAIS knowledge base lookup fails (DB error, embedding error, config missing). + + Attributes: + status_code: Optional HTTP status code (from OCI embedding errors). + url: Optional URL/endpoint that was called. + response_text: Optional response body. + latency_ms: Optional operation latency in milliseconds. + """ + + def __init__( + self, + message: str, + status_code: int | None = None, + *, + url: str | None = None, + response_text: str | None = None, + latency_ms: int | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.url = url + self.response_text = response_text + self.latency_ms = latency_ms + + def __str__(self) -> str: + base = super().__str__() + if self.status_code: + return f"[HTTP {self.status_code}] {base}" + return base diff --git a/src/components/clients/imdb_client.py b/src/components/clients/imdb_client.py new file mode 100644 index 0000000..d28140e --- /dev/null +++ b/src/components/clients/imdb_client.py @@ -0,0 +1,222 @@ +import json +import asyncio +import random +import httpx +import logging +import time +from src.utils.observer import trace_tool +from src.utils.http import traced_async_client + +from httpx import Timeout +from typing import Optional, Tuple, Dict, Any +from src.core.config import settings +from src.components.clients.exceptions.imdb_exceptions import ( + ImdbClientError, + ImdbHttpError, + ImdbConnectionError, + ImdbTimeoutError, + ImdbExceededRetriesError +) +from src.api.schemas.imdb_schemas import ImdbResponse + +logger = logging.getLogger(__name__) + +class ImdbClient: + """ + Client for the IMDB API to retrieve IMDB access data. + """ + + def __init__( + self, + base_url: Optional[str] = None, + basic_token: Optional[str] = None + ): + + self.timeout = Timeout(5.0, + connect=float(settings.PMID_API_TIMEOUT) + ) + self.base_url = base_url or settings.PMID_API_HOST + self.basic_token = basic_token or settings.PMID_API_BASIC_TOKEN + + self._RETRYABLE_NETWORK_ERRORS = ( + ImdbTimeoutError, + ImdbConnectionError + ) + self._RETRYABLE_STATUS_CODES = {429, 503, 504} + + + def _is_retryable(self, exc: Exception) -> bool: + """ + Returns True only to transient erros that must be retried. + """ + if isinstance(exc, self._RETRYABLE_NETWORK_ERRORS): + return True + + if isinstance(exc, ImdbHttpError): + return exc.status_code in self._RETRYABLE_STATUS_CODES + + return False + + def _backoff(self, attempt: int, base: float = 1.0, cap: float = 30.0) -> float: + return random.uniform(0, min(cap, base * (2 ** attempt))) + + def _build_headers(self, client_id: str) -> dict[str, str]: + """Builds headers for the API request.""" + headers = { + "Authorization": self.basic_token, + "clientId": client_id, + "Accept": "application/json" + } + return headers + + def _build_url(self, msisdn: str) -> str: + """Builds the API endpoint URL.""" + return f"{self.base_url}/{msisdn}" + + def _parse_response(self, response: httpx.Response) -> ImdbResponse: + """ + Parses and validates the API response for JSON exceptions. + """ + try: + response_json = response.json() + enrichment_fields = { + "plan": response_json.get("plan"), + "statusType": response_json.get("statusType"), + "statusDescription": response_json.get("statusDescription"), + "socialSecNo": response_json.get("socialSecNo"), + } + return ImdbResponse(**enrichment_fields) + except json.JSONDecodeError as exc: + logger.error("Failed to parse JSON from IMDB API.", exc_info=True) + raise ImdbClientError("Invalid JSON response from IMDB API") from exc + + @trace_tool + async def get_imdb_access_data( + self, + msisdn: str, + client_id: str + ) -> Tuple[Optional[ImdbResponse], Dict[str, Any]]: + """ + Fetches IMDB access data asynchronously. + + Args: + msisdn: Customer MSISDN. + client_id: Client identification. + + Returns: + Tuple of (response, http_meta) where: + - response: ImdbResponse with IMDB access information, or None for 204 (no TIM number). + - http_meta: Dict with keys url, status_code, response_text, latency_ms. + + Raises: + ImdbClientError: For any API or connection issues. + """ + try: + url = self._build_url(msisdn) + headers = self._build_headers(client_id) + + start = time.perf_counter() + + async with traced_async_client(timeout=self.timeout) as client: + response = await client.get(url, headers=headers) + latency_ms = int((time.perf_counter() - start) * 1000) + + if response.status_code == 204 or not response.content: + logger.warning(f"IMDB API returned {response.status_code} with no content for the received msisdn.") + http_meta = { + "url": url, + "status_code": response.status_code, + "response_text": response.text, + "latency_ms": latency_ms, + } + return None, http_meta + + response.raise_for_status() + + message_id = response.headers.get("Messageid") + logger.info(f"IMDB Transaction's Message ID: {message_id}") + + http_meta = { + "url": url, + "status_code": response.status_code, + "response_text": response.text, + "latency_ms": latency_ms, + } + return self._parse_response(response), http_meta + + except httpx.HTTPStatusError as exc: + latency_ms = int((time.perf_counter() - start) * 1000) if 'start' in locals() else 0 + logger.error(f"HTTP error {exc.response.status_code} when trying to fetch IMDB's API.", exc_info=True) + raise ImdbHttpError( + exc.response.status_code, + f"HTTP error {exc.response.status_code} when consulting IMDB's API", + url=url, + response_text=exc.response.text, + latency_ms=latency_ms + ) from exc + + except httpx.TimeoutException as exc: + latency_ms = int((time.perf_counter() - start) * 1000) if 'start' in locals() else 0 + logger.error(f"Request to IMDB's API timed out after {self.timeout.connect}s", exc_info=True) + + raise ImdbTimeoutError( + self.base_url, + self.timeout.connect, + latency_ms=latency_ms + ) from exc + + except httpx.RequestError as exc: + latency_ms = int((time.perf_counter() - start) * 1000) if 'start' in locals() else 0 + detail = str(exc) or repr(exc) + logger.error(f"Connection error at IMDB's API: {detail}", exc_info=True) + + raise ImdbConnectionError( + self.base_url, + reason=detail, + latency_ms=latency_ms + ) from exc + + except Exception as exc: + latency_ms = int((time.perf_counter() - start) * 1000) if 'start' in locals() else 0 + detail = str(exc) or repr(exc) + logger.error(f"Unexpected error at IMDB API: {detail}", exc_info=True) + raise ImdbClientError(f"Unexpected error at IMDB's API: {detail}", url=self.base_url, latency_ms=latency_ms) from exc + + async def get_imdb_access_data_with_retry(self, + msisdn: str, + client_id: str, + max_retries: int = 3 + ) -> Tuple[Optional[ImdbResponse], Dict[str, Any]]: + for attempt in range(max_retries + 1): + try: + response, http_meta = await self.get_imdb_access_data(msisdn, client_id) + http_meta["retry_count"] = attempt + return response, http_meta + except ImdbClientError as imdb_err: + if not self._is_retryable(imdb_err): + # 401, 403, 500, etc. + raise + + if attempt == max_retries: + raise ImdbExceededRetriesError( + self.base_url, + max_retries, + imdb_err + ) + + wait = self._backoff(attempt) + logger.warning( + "IMDB transient error, retrying", + extra={ + "operation": { + "name": "get_imdb_access_data_with_retry", + "status": "in_progress", + "attempt": attempt + 1, + "max_retries": max_retries, + "backoff_seconds": round(wait, 3), + "last_error": str(imdb_err), + }, + "component": "imdb_client", + }, + ) + await asyncio.sleep(wait) diff --git a/src/components/clients/portability_client.py b/src/components/clients/portability_client.py new file mode 100644 index 0000000..02ce021 --- /dev/null +++ b/src/components/clients/portability_client.py @@ -0,0 +1,226 @@ +import json +import asyncio +import random +import time +import httpx +import logging + +from httpx import Timeout +from typing import Optional, Tuple, Any +from src.core.config import settings +from src.components.clients.exceptions.portability_exceptions import ( + PortabilityClientError, + PortabilityHttpError, + PortabilityConnectionError, + PortabilityTimeoutError, + PortabilityNoContentError, + PortabilityExceededRetriesError +) +from src.api.schemas.portability_schemas import PortabilityResponse +from src.utils.observer import trace_tool +from src.utils.http import traced_async_client + +logger = logging.getLogger(__name__) + + +class PortabilityClient: + """ + Client for the Portability API to retrieve portability history + based on social security number and MSISDN. + """ + + def __init__( + self, + base_url: Optional[str] = None, + authorization: Optional[str] = None, + client_id: Optional[str] = None, + ): + self.timeout = Timeout(5.0, connect=float(settings.PORTABILITY_API_TIMEOUT)) + self.base_url = base_url or settings.PORTABILITY_API_URL + self.authorization = authorization or settings.PORTABILITY_API_AUTHORIZATION + self.client_id = client_id or settings.PORTABILITY_API_CLIENT_ID + + self._RETRYABLE_NETWORK_ERRORS = ( + PortabilityTimeoutError, + PortabilityConnectionError, + ) + self._RETRYABLE_STATUS_CODES = {429, 503, 504} + + def _is_retryable(self, exc: Exception) -> bool: + """ + Returns True only for transient errors that should be retried. + """ + if isinstance(exc, self._RETRYABLE_NETWORK_ERRORS): + return True + + if isinstance(exc, PortabilityHttpError): + return exc.status_code in self._RETRYABLE_STATUS_CODES + + return False + + def _backoff(self, attempt: int, base: float = 1.0, cap: float = 30.0) -> float: + return random.uniform(0, min(cap, base * (2 ** attempt))) + + def _build_headers(self) -> dict[str, str]: + """Builds headers for the API request.""" + return { + "Accept": "application/json", + "clientId": self.client_id, + "Authorization": settings.PORTABILITY_API_AUTHORIZATION, + } + + def _build_params(self, social_sec_no: str, msisdn: str) -> dict[str, str]: + """Builds the query string parameters.""" + return { + "msisdn": msisdn, + "socialSecNo": social_sec_no, + "monthsNumber": "120", + } + + def _parse_response(self, response: httpx.Response) -> PortabilityResponse: + """ + Parses and validates the API response. + """ + try: + response_json = response.json() + return PortabilityResponse(**response_json) + except json.JSONDecodeError as exc: + logger.error("Failed to parse JSON from Portability API.", exc_info=True) + raise PortabilityClientError("Invalid JSON response from Portability API") from exc + + @trace_tool + async def get_portability_history( + self, + social_sec_no: str, + msisdn: str, + ) -> Tuple[dict, dict[str, Any]]: + """Calls the Portability API and returns (response_dict, http_meta). + + response_dict has the legacy shape {"status_code", "data"} kept for + backward compatibility with the consumers in undefined_complaint_operator_node. + http_meta has the shape {url, status_code, response_text, latency_ms}, + same contract used by SiebelClient/ImdbClient/AbrtClient/TaisKbClient + so that callers can build IC payloads uniformly. + + On error, raises PortabilityClientError (or subclass) carrying the same + attributes (url, status_code, response_text, latency_ms) so the caller + can read them via `getattr(exc, "<field>", None)`. + """ + start = time.perf_counter() + try: + headers = self._build_headers() + params = self._build_params(social_sec_no, msisdn) + + async with traced_async_client(timeout=self.timeout) as client: + response = await client.get( + self.base_url, + headers=headers, + params=params, + ) + latency_ms = int((time.perf_counter() - start) * 1000) + response_text = response.text + status_code = response.status_code + + response.raise_for_status() + + message_id = response.headers.get("Messageid") + logger.info(f"Portability Transaction's Message ID: {message_id}") + + http_meta = { + "url": self.base_url, + "status_code": status_code, + "response_text": response_text[:500] if response_text else "", + "latency_ms": latency_ms, + } + + if not response.content: + logger.warning( + f"Portability API returned {status_code} with empty content " + f"for msisdn={msisdn}." + ) + return {"status_code": status_code, "data": None}, http_meta + + parsed = self._parse_response(response) + + return {"status_code": status_code, "data": parsed}, http_meta + + except httpx.HTTPStatusError as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + response_text = exc.response.text if exc.response is not None else "" + logger.error( + f"HTTP error {exc.response.status_code} when trying to fetch Portability API." + , exc_info=True) + raise PortabilityHttpError( + exc.response.status_code, + f"HTTP error {exc.response.status_code} when consulting Portability API", + url=self.base_url, + response_text=response_text[:500] if response_text else "", + latency_ms=latency_ms, + ) from exc + + except httpx.TimeoutException as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + logger.error( + f"Request to Portability API timed out after {self.timeout.connect}s" + , exc_info=True) + raise PortabilityTimeoutError( + self.base_url, + self.timeout.connect, + latency_ms=latency_ms, + ) from exc + + except httpx.RequestError as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + detail = str(exc) or repr(exc) + logger.error(f"Connection error at Portability API: {detail}", exc_info=True) + raise PortabilityConnectionError( + self.base_url, + reason=detail, + latency_ms=latency_ms, + ) from exc + + except Exception as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + detail = str(exc) or repr(exc) + logger.error(f"Unexpected error at Portability API: {detail}", exc_info=True) + raise PortabilityClientError( + f"Unexpected error at Portability API: {detail}", + url=self.base_url, + response_text=detail[:500] if detail else "", + latency_ms=latency_ms, + ) from exc + + async def get_portability_history_with_retry( + self, + social_sec_no: str, + msisdn: str, + max_retries: int = 3, + ) -> Tuple[dict, dict[str, Any]]: + """Returns (response_dict, http_meta) following the same contract as + AbrtClient.get_abrt_data_with_retry. http_meta includes a "retry_count" + field with the number of retries performed. + """ + for attempt in range(max_retries + 1): + try: + response, http_meta = await self.get_portability_history(social_sec_no, msisdn) + http_meta["retry_count"] = attempt + return response, http_meta + except PortabilityClientError as err: + if not self._is_retryable(err): + # Non-retryable errors: 401, 403, 500, etc. + raise + + if attempt == max_retries: + raise PortabilityExceededRetriesError( + self.base_url, + max_retries, + err, + latency_ms=getattr(err, "latency_ms", None), + ) + + wait = self._backoff(attempt) + logger.warning( + f"Transient error (attempt {attempt + 1}/{max_retries}), " + f"retrying Portability API call. Last error: {err}" + ) + await asyncio.sleep(wait) \ No newline at end of file diff --git a/src/components/clients/rag/__init__.py b/src/components/clients/rag/__init__.py new file mode 100644 index 0000000..c7e1894 --- /dev/null +++ b/src/components/clients/rag/__init__.py @@ -0,0 +1,17 @@ +""" +Clients dos RAGs especializados consumidos pelo grafo do Response Emulator. + +Cada client encapsula uma coleção do Autonomous DB Vector Search. Por ora +todos retornam dados mockados a partir de `src/utils/mocks/emulador/*.json` +— quando o cliente entregar o formato real, troca-se apenas a implementação +interna; a interface (`async def search`) e os nós que consomem permanecem +inalterados. +""" + +from src.components.clients.rag.templates_rag_client import templates_rag_client +from src.components.clients.rag.history_rag_client import history_rag_client + +__all__ = [ + "templates_rag_client", + "history_rag_client", +] diff --git a/src/components/clients/rag/__pycache__/__init__.cpython-313.pyc b/src/components/clients/rag/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c08dd8bad04e7ce73a21d9ebccc685d3c1cc79c9 GIT binary patch literal 858 zcmaJ=v2GMG5cTfeCDB3}M1e#zN|i%oEzyFILO_TFP{>KNzQH?t*<@`m_AV%?;0tKr z3n&mZpOBwGC(%Gf7ZlMm-UNjr!i9G{<C*u~jQ3=DITO5Yt*`7Irb7IPhrf%@{W@9Y z&j&FPUQCinx|M9EK80uxwg#I+KV*A28OhX-W((uR?p-28wozAP+W>_Nu(SR!0ELEh zg>`j^(WjjaovEVPQ0-vmv2=VJcK9O42p*WO#@6{f%Qmn;(0xT9tFZ|jg3hqh)pYdf z=wqL{-nGs;<GKL%@4<7@oDCB~uXl6UX4`qpS{E@ram=EDqGw~A-tP^B27Oz3C<vy( zYuU9`9b}9LNs&?Eyu6X`1!t$(_c!n1Wrvn&yEA^o1Y1utWHcz9H!OkCu)YbccN*7N z88&1w077M&nhYmkuOX{!OU&wa3`Mg<O|Uw}a9}kvDk1b@reh#r5%%ayV0`ME=?oce zB?DU0WVjaATq3H<v!8L0<79rdC5FV7g7P?1r!8gwmE9_ELk|A&`T?i-A#YE|3-d*# z3a6EtOKwW8o#7e9`R1o3LeAy#i;DMU=4=1Sx&S(&Mo6q{r9O)jK8m@O#<sGMPbQCG z+q*p;Z;&`+7OM1U=(Y3v%IQkJK)vtEveKmZmAxXKXW`E8L<RC(fjm<nS%FllvQ?{8 z-W<%A?ig2eHQn`>8J^#?2rIlD50WJLl`bX2-&aNQEcqrLA1{c}^{>Ng$D_fe^zEXL GztkVnI3Cyl literal 0 HcmV?d00001 diff --git a/src/components/clients/rag/__pycache__/history_rag_client.cpython-313.pyc b/src/components/clients/rag/__pycache__/history_rag_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c2cafb5ecc2ec72bf9d8d5a04911dfa0bea69ec8 GIT binary patch literal 8091 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 Z8yt6*drIs_!9`m4Ew}71kw#0S{u9^8D?0!H literal 0 HcmV?d00001 diff --git a/src/components/clients/rag/__pycache__/templates_rag_client.cpython-313.pyc b/src/components/clients/rag/__pycache__/templates_rag_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..415f0ccc9683b373e37e0bb03a3d500a0b4bc5f6 GIT binary patch literal 4952 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 F{~zQ`(fI%X literal 0 HcmV?d00001 diff --git a/src/components/clients/rag/history_rag_client.py b/src/components/clients/rag/history_rag_client.py new file mode 100644 index 0000000..c407a2e --- /dev/null +++ b/src/components/clients/rag/history_rag_client.py @@ -0,0 +1,168 @@ +""" +Client do RAG de Histórico Operacional (history_collection). + +Adapter fino sobre `EmulatorRagClient`, fonte `anatel_resposta` (chunks de +respostas Anatel anteriores). A busca é dividida em dois baldes por `nota`: +- "high_score": `nota >= EMULATOR_RAG_HISTORY_HIGH_SCORE_THRESHOLD` (referências positivas); +- "low_score": `nota <= EMULATOR_RAG_HISTORY_LOW_SCORE_THRESHOLD` (exemplos a evitar). + +`search_examples(query, top_k_high_score, top_k_low_score)` retorna +`{"high_score": [...], "low_score": [...]}` e é a interface consumida pelo +`retrieve_history_node`. `search(query, top_k)` segue disponível para buscas +de balde único (ex.: rota de QA). + +Fallback ao mock local em `src/utils/mocks/emulador/kb_history.json` é +gated por `settings.EMULATOR_RAG_ALLOW_MOCK_FALLBACK` — só dispara em dev +local. Em ambientes compartilhados (dev kube, fqa, prod) a flag fica em +False e qualquer falha do backend real é propagada como +`EmulatorRagClientError`; o `retrieve_history_node` já trata e segue com +lista vazia. +""" + +import json +import logging +from pathlib import Path +from typing import Any + +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 + +logger = logging.getLogger(__name__) + +_MOCK_PATH = ( + Path(__file__).resolve().parents[3] / "utils" / "mocks" / "emulador" / "kb_history.json" +) + + +def _real_client_configured() -> bool: + return bool( + settings.EMULATOR_RAG_OCI_GENAI_ENDPOINT + and settings.EMULATOR_RAG_COMPARTMENT_ID + and settings.TAIS_DB_DSN + ) + + +def _load_mock() -> list[dict[str, Any]]: + try: + with _MOCK_PATH.open(encoding="utf-8") as fp: + data = json.load(fp) + return data.get("history", []) or [] + except Exception as exc: + logger.warning("Falha ao carregar mock de histórico: %s", exc, exc_info=True) + return [] + + +def _mock_filter_by_nota( + items: list[dict[str, Any]], nota_min: int | None, nota_max: int | None +) -> list[dict[str, Any]]: + """Best-effort nota filter for the local mock (dev only). + + Mock entries may not carry `nota`; those are kept so dev runs still see + data instead of an empty bucket. + """ + filtered = [] + for item in items: + nota = (item.get("metadata") or {}).get("nota", item.get("nota")) + if nota is None: + filtered.append(item) + continue + if nota_min is not None and nota < nota_min: + continue + if nota_max is not None and nota > nota_max: + continue + filtered.append(item) + return filtered + + +class HistoryRagClient: + """Interface estável de busca semântica na base de histórico operacional.""" + + def __init__(self) -> None: + self._client = EmulatorRagClient() + + async def search( + self, + query: str, + top_k: int = 5, + nota_min: int | None = None, + nota_max: int | None = None, + ) -> list[dict[str, Any]]: + """Retorna até `top_k` históricos similares a `query` num único balde. + + `nota_min`/`nota_max` delimitam a faixa de nota (inclusiva). Quando + ambos são `None`, usa o threshold de nota alta como piso (compat). + Levanta `EmulatorRagClientError` quando o backend real não está + configurado e o fallback de mock não está habilitado. + """ + if nota_min is None and nota_max is None: + nota_min = settings.EMULATOR_RAG_HISTORY_HIGH_SCORE_THRESHOLD + + allow_mock = settings.EMULATOR_RAG_ALLOW_MOCK_FALLBACK + + if not _real_client_configured(): + if not allow_mock: + raise EmulatorRagClientError( + "HistoryRAG backend not configured and mock fallback disabled " + "(EMULATOR_RAG_ALLOW_MOCK_FALLBACK=false)." + ) + history = _mock_filter_by_nota(_load_mock(), nota_min, nota_max)[:top_k] + logger.info( + "HistoryRAG mock | query=%r | nota_min=%s | nota_max=%s | returned=%d", + query, nota_min, nota_max, len(history), + ) + return history + + try: + response = await self._client.search( + source=EmulatorRagSource.anatel_resposta, + query=query, + nota_min=nota_min, + nota_max=nota_max, + top_k=top_k, + ) + except EmulatorRagClientError: + if not allow_mock: + raise + logger.warning( + "HistoryRAG fell back to mock after backend error (local dev only)", + exc_info=True, + ) + return _mock_filter_by_nota(_load_mock(), nota_min, nota_max)[:top_k] + + results = response.get("results", []) + logger.info( + "HistoryRAG | query=%r | nota_min=%s | nota_max=%s | returned=%d", + query, nota_min, nota_max, len(results), + ) + return results + + async def search_examples( + self, query: str, top_k_high_score: int, top_k_low_score: int + ) -> dict[str, list[dict[str, Any]]]: + """Retorna respostas similares separadas em baldes high_score/low_score por nota. + + - `high_score`: `nota >= EMULATOR_RAG_HISTORY_HIGH_SCORE_THRESHOLD` (referências), + limitado a `top_k_high_score`; + - `low_score`: `nota <= EMULATOR_RAG_HISTORY_LOW_SCORE_THRESHOLD` (exemplos a evitar), + limitado a `top_k_low_score`. + + Cada balde é uma busca vetorial independente — top_ks distintos + permitem, por exemplo, puxar mais referências de nota alta e menos + exemplos de nota baixa sem inflar tokens à toa. Propaga + `EmulatorRagClientError` — o nó consumidor trata. + """ + high_score = await self.search( + query=query, + top_k=top_k_high_score, + nota_min=settings.EMULATOR_RAG_HISTORY_HIGH_SCORE_THRESHOLD, + ) + low_score = await self.search( + query=query, + top_k=top_k_low_score, + nota_max=settings.EMULATOR_RAG_HISTORY_LOW_SCORE_THRESHOLD, + ) + return {"high_score": high_score, "low_score": low_score} + + +history_rag_client = HistoryRagClient() diff --git a/src/components/clients/rag/templates_rag_client.py b/src/components/clients/rag/templates_rag_client.py new file mode 100644 index 0000000..0f82833 --- /dev/null +++ b/src/components/clients/rag/templates_rag_client.py @@ -0,0 +1,96 @@ +""" +Client do RAG de Templates (templates_collection). + +Adapter fino sobre `EmulatorRagClient` (Oracle ADB + OCI Cohere). A interface +`async def search(query, top_k)` é estável: os nodes consumidores não mudam. + +Fallback ao mock local em `src/utils/mocks/emulador/kb_templates.json` é +gated por `settings.EMULATOR_RAG_ALLOW_MOCK_FALLBACK` — só dispara em dev +local. Em ambientes compartilhados (dev kube, fqa, prod) a flag fica em +False e qualquer falha do backend real é propagada como +`EmulatorRagClientError`; o `retrieve_templates_node` já trata e segue com +lista vazia. +""" + +import json +import logging +from pathlib import Path +from typing import Any + +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 + +logger = logging.getLogger(__name__) + +_MOCK_PATH = ( + Path(__file__).resolve().parents[3] / "utils" / "mocks" / "emulador" / "kb_templates.json" +) + + +def _real_client_configured() -> bool: + return bool( + settings.EMULATOR_RAG_OCI_GENAI_ENDPOINT + and settings.EMULATOR_RAG_COMPARTMENT_ID + and settings.TAIS_DB_DSN + ) + + +def _load_mock() -> list[dict[str, Any]]: + try: + with _MOCK_PATH.open(encoding="utf-8") as fp: + data = json.load(fp) + return data.get("templates", []) or [] + except Exception as exc: + logger.warning("Falha ao carregar mock de templates: %s", exc, exc_info=True) + return [] + + +class TemplatesRagClient: + """Interface estável de busca semântica na base de templates IQI.""" + + def __init__(self) -> None: + self._client = EmulatorRagClient() + + async def search(self, query: str, top_k: int = 5) -> list[dict[str, Any]]: + """Retorna até `top_k` templates relevantes para `query`. + + Levanta `EmulatorRagClientError` quando o backend real não está + configurado e o fallback de mock não está habilitado. + """ + allow_mock = settings.EMULATOR_RAG_ALLOW_MOCK_FALLBACK + + if not _real_client_configured(): + if not allow_mock: + raise EmulatorRagClientError( + "TemplatesRAG backend not configured and mock fallback disabled " + "(EMULATOR_RAG_ALLOW_MOCK_FALLBACK=false)." + ) + templates = _load_mock() + logger.info( + "TemplatesRAG mock | query=%r | returned=%d (top_k=%d ignored)", + query, len(templates), top_k, + ) + return templates + + try: + response = await self._client.search( + source=EmulatorRagSource.templates, + query=query, + top_k=top_k, + ) + except EmulatorRagClientError: + if not allow_mock: + raise + logger.warning( + "TemplatesRAG fell back to mock after backend error (local dev only)", + exc_info=True, + ) + return _load_mock() + + results = response.get("results", []) + logger.info("TemplatesRAG | query=%r | returned=%d", query, len(results)) + return results + + +templates_rag_client = TemplatesRagClient() diff --git a/src/components/clients/siebel_client.py b/src/components/clients/siebel_client.py new file mode 100644 index 0000000..f3b7ec3 --- /dev/null +++ b/src/components/clients/siebel_client.py @@ -0,0 +1,355 @@ +import httpx +import base64 +import asyncio +import random +import logging +import time +from typing import Any, Dict, Optional, Tuple +import time +from typing import Any, Dict, Optional, Tuple +from src.core.config import settings +from src.components.clients.exceptions.siebel_exceptions import ( + SiebelClientError, + SiebelHttpError, + SiebelConnectionError, + SiebelTimeoutError, + SiebelExceededRetriesError, +) +from src.api.schemas.siebel_schemas import SiebelSRRequest +from src.utils.observer import trace_tool +from src.utils.http import traced_async_client + +logger = logging.getLogger(__name__) + +class SiebelClient: + """ + Client to interact with Siebel CRM for Service Request operations. + """ + + def __init__( + self, + base_url: Optional[str] = None, + route: Optional[str] = None, + timeout: Optional[int] = None, + verify_ssl: Optional[bool] = None, + username: Optional[str] = None, + password: Optional[str] = None, + client_id: Optional[str] = None + ): + host = base_url or settings.SIEBEL_API_HOST or "" + path = route if route is not None else settings.SIEBEL_API_ROUTE + self.base_url = f"{host.rstrip('/')}{path}" + self.timeout = timeout if timeout is not None else settings.SIEBEL_API_TIMEOUT + self.verify_ssl = verify_ssl if verify_ssl is not None else settings.VERIFY_SSL + self.username = username or settings.SIEBEL_API_USERNAME + self.password = password or settings.SIEBEL_API_PASSWORD + self.client_id = client_id or settings.SIEBEL_API_CLIENT_ID + + prospect_host = settings.SIEBEL_PROSPECT_API_HOST or "" + prospect_path = settings.SIEBEL_PROSPECT_API_ROUTE or "" + self.prospect_auth = settings.SIEBEL_PROSPECT_API_AUTHORIZATION or None + self.prospect_url = f"{prospect_host.rstrip('/')}{prospect_path}" + + self._RETRYABLE_NETWORK_ERRORS = ( + SiebelTimeoutError, + SiebelConnectionError, + ) + self._RETRYABLE_STATUS_CODES = {429, 503, 504} + + def _is_retryable(self, exc: Exception) -> bool: + if isinstance(exc, self._RETRYABLE_NETWORK_ERRORS): + return True + if isinstance(exc, SiebelHttpError): + return exc.status_code in self._RETRYABLE_STATUS_CODES + return False + + def _backoff(self, attempt: int, base: float = 1.0, cap: float = 30.0) -> float: + return random.uniform(0, min(cap, base * (2 ** attempt))) + + @staticmethod + def _ensure_absolute_url(url: str, env_var_name: str, endpoint_label: str) -> None: + """Garante que `url` é absoluta (http:// ou https://). + + Caso contrário, levanta SiebelClientError indicando a env var faltante. + Erro não-retryable: falha de configuração não se resolve com retentativas. + """ + if not url or not url.lower().startswith(("http://", "https://")): + raise SiebelClientError( + f"URL inválida para {endpoint_label}: {url!r}. " + f"Verifique a variável de ambiente {env_var_name}.", + url=url or "N/A", + latency_ms=0, + ) + + def _get_auth_header(self, username: Optional[str] = None, password: Optional[str] = None) -> str: + """Generates Basic Auth header. Defaults to instance credentials when no overrides given.""" + user = username if username is not None else self.username + pwd = password if password is not None else self.password + if not user or not pwd: + return "" + auth_str = f"{user}:{pwd}" + encoded_auth = base64.b64encode(auth_str.encode()).decode() + return f"Basic {encoded_auth}" + + @trace_tool + async def open_service_request( + self, + payload: Dict[str, Any], + prospect: bool = False, + ) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """ + Opens a Service Request in Siebel. + + + + Args: + payload: Serialized payload to open a Service Request in Siebel. + prospect: When True, target the Siebel Prospect API (non-TIM / canceled + customers) using its dedicated host, route and credentials. + + Returns: + Tuple of (response_body, http_meta) where: + - response_body: Dict[str, Any] - Response from the Siebel API. + - http_meta: Dict with keys url, status_code, response_text, latency_ms. + + Tuple of (response_body, http_meta) where: + - response_body: Dict[str, Any] - Response from the Siebel API. + - http_meta: Dict with keys url, status_code, response_text, latency_ms. + + Raises: + SiebelClientError: If the Siebel API returns a non-2xx HTTP status code. + SiebelConnectionError: If a connection to the Siebel API cannot be established. + SiebelTimeoutError: If a request to the Siebel API exceeds the configured timeout. + """ + if prospect: + url = self.prospect_url + auth_header = self.prospect_auth + label = "Siebel Prospect" + else: + url = self.base_url + username = self.username + password = self.password + auth_header = self._get_auth_header(username, password) + label = "Siebel" + + client_id = self.client_id + + headers = {"Content-Type": "application/json"} + + if auth_header: + headers["Authorization"] = auth_header + + if client_id: + headers["clientId"] = client_id + + if prospect: + self._ensure_absolute_url(url, "SIEBEL_PROSPECT_API_HOST", "abertura de SR (Siebel Prospect)") + else: + self._ensure_absolute_url(url, "SIEBEL_API_HOST", "abertura de SR (Siebel)") + + logger.info(f"Opening {label} SR on endpoint {url}.") + + start = time.perf_counter() + + try: + async with traced_async_client(verify_ssl=self.verify_ssl, timeout=self.timeout) as client: + response = await client.post(url, json=payload, headers=headers) + latency_ms = int((time.perf_counter() - start) * 1000) + response.raise_for_status() + + message_id = response.headers.get("MessageId") + logger.info(f"{label} SR opened and recieved Message ID: {message_id}") + + http_meta = { + "url": self.base_url, + "status_code": response.status_code, + "response_text": response.text, + "latency_ms": latency_ms, + } + + if response.status_code == 204 or not (response.text or "").strip(): + raise SiebelClientError( + f"Siebel returned {response.status_code} no content — couldn't get crmProtocol. Endpoint: {url}, Message ID: {message_id}", + url=self.base_url, + latency_ms=latency_ms, + ) + + return response.json(), http_meta + + except httpx.HTTPStatusError as e: + latency_ms = int((time.perf_counter() - start) * 1000) + logger.error(f"{label} API HTTP error {e.response.status_code}: {e.response.text}", exc_info=True) + raise SiebelHttpError(e.response.status_code, e.response.text, url=self.base_url, latency_ms=latency_ms) + except httpx.ConnectError as e: + latency_ms = int((time.perf_counter() - start) * 1000) + latency_ms = int((time.perf_counter() - start) * 1000) + detail = str(e) or repr(e) + logger.error(f"{label} API connection error: {detail}", exc_info=True) + raise SiebelConnectionError(detail, url=self.base_url, latency_ms=latency_ms) + except httpx.TimeoutException as e: + latency_ms = int((time.perf_counter() - start) * 1000) + latency_ms = int((time.perf_counter() - start) * 1000) + detail = str(e) or f"{type(e).__name__} after {self.timeout}s" + logger.error(f"{label} API timeout: {detail}", exc_info=True) + raise SiebelTimeoutError(detail, url=self.base_url, latency_ms=latency_ms) + except SiebelClientError: + raise + except Exception as e: + latency_ms = int((time.perf_counter() - start) * 1000) + latency_ms = int((time.perf_counter() - start) * 1000) + detail = str(e) or repr(e) + logger.error(f"Unexpected error calling {label}: {detail}", exc_info=True) + raise SiebelClientError(f"Unexpected error: {detail}", url=self.base_url, latency_ms=latency_ms) + + @trace_tool + async def update_service_request_status( + self, + payload: Dict[str, Any], + plan_type: str = "pós-pago", + ) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """ + Updates the status of a Service Request in Siebel (SR closing). + + Args: + payload: Serialized payload from SiebelSRStatusRequestPosPago.to_payload() / SiebelSRStatusRequestPrePago.to_payload(). + plan_type: Customer plan type from IMDB (plan.Type). Only "pré-pago"/"express" route + to the pré-pago endpoint — everything else uses pós-pago. + + Returns: + Tuple of (response_body, http_meta). + + Raises: + SiebelClientError: On HTTP error, connection failure, timeout, or missing config. + """ + is_prepago = (plan_type or "").strip().lower() in {"pré-pago", "express"} + + if is_prepago: + prepago_host = settings.SIEBEL_PREPAGO_STATUS_API_HOST or "" + if not prepago_host: + raise SiebelClientError( + "Endpoint Siebel pré-pago não configurado (SIEBEL_PREPAGO_STATUS_API_HOST). " + "Aguardando definição pela TIM.", + url="N/A", + latency_ms=0, + ) + url = f"{prepago_host.rstrip('/')}{settings.SIEBEL_PREPAGO_STATUS_API_ROUTE}" + label = "Siebel Pré-pago Status" + self._ensure_absolute_url(url, "SIEBEL_PREPAGO_STATUS_API_HOST", "atualização status pré-pago") + else: + status_host = settings.SIEBEL_STATUS_API_HOST or settings.SIEBEL_API_HOST or "" + url = f"{status_host.rstrip('/')}{settings.SIEBEL_STATUS_API_ROUTE}" + label = "Siebel Pós-pago Status" + self._ensure_absolute_url(url, "SIEBEL_STATUS_API_HOST", "atualização status pós-pago") + + auth_header = self.prospect_auth + headers = {"Content-Type": "application/json"} + if auth_header: + headers["Authorization"] = auth_header + if self.client_id: + headers["clientId"] = self.client_id + + logger.info(f"Updating SR status via {label} on endpoint {url}.") + + start = time.perf_counter() + + try: + async with traced_async_client(verify_ssl=self.verify_ssl, timeout=self.timeout) as client: + if is_prepago: + response = await client.patch(url, json=payload, headers=headers) + else: + response = await client.post(url, json=payload, headers=headers) + latency_ms = int((time.perf_counter() - start) * 1000) + response.raise_for_status() + + http_meta = { + "url": url, + "status_code": response.status_code, + "response_text": response.text, + "latency_ms": latency_ms, + } + logger.info(f"{label} SR status updated successfully.") + try: + return response.json(), http_meta + except Exception: + # 204 No Content (ou body vazio) é resposta válida para update de status. + return {}, http_meta + + except httpx.HTTPStatusError as e: + latency_ms = int((time.perf_counter() - start) * 1000) + logger.error(f"{label} HTTP error {e.response.status_code}: {e.response.text}", exc_info=True) + raise SiebelHttpError(e.response.status_code, e.response.text, url=url, latency_ms=latency_ms) + except httpx.ConnectError as e: + latency_ms = int((time.perf_counter() - start) * 1000) + detail = str(e) or repr(e) + logger.error(f"{label} connection error: {detail}", exc_info=True) + raise SiebelConnectionError(detail, url=url, latency_ms=latency_ms) + except httpx.TimeoutException as e: + latency_ms = int((time.perf_counter() - start) * 1000) + detail = str(e) or f"{type(e).__name__} after {self.timeout}s" + logger.error(f"{label} timeout: {detail}", exc_info=True) + raise SiebelTimeoutError(detail, url=url, latency_ms=latency_ms) + except SiebelClientError: + raise + except Exception as e: + latency_ms = int((time.perf_counter() - start) * 1000) + detail = str(e) or repr(e) + logger.error(f"Unexpected error calling {label}: {detail}", exc_info=True) + raise SiebelClientError(f"Unexpected error: {detail}", url=url, latency_ms=latency_ms) + + async def update_service_request_status_with_retry( + self, + payload: Dict[str, Any], + max_retries: int = 2, + plan_type: str = "pós-pago", + ) -> Tuple[Dict[str, Any], Dict[str, Any]]: + for attempt in range(max_retries + 1): + try: + response, http_meta = await self.update_service_request_status(payload, plan_type) + http_meta["retry_count"] = attempt + return response, http_meta + except SiebelClientError as siebel_err: + if not self._is_retryable(siebel_err): + raise + + if attempt == max_retries: + raise SiebelExceededRetriesError( + getattr(siebel_err, "url", "N/A"), + max_retries, + siebel_err, + ) + + wait = self._backoff(attempt) + logger.warning( + f"Transient error (attempt {attempt + 1}/{max_retries}), retrying SR status update. Last error: {siebel_err}" + ) + await asyncio.sleep(wait) + + async def open_service_request_with_retry( + self, + payload: Dict[str, Any], + max_retries: int = 3, + prospect: bool = False, + ) -> Tuple[Dict[str, Any], Dict[str, Any]]: + target_url = self.prospect_url if prospect else self.base_url + label = "Siebel Prospect" if prospect else "Siebel" + for attempt in range(max_retries + 1): + try: + response, http_meta = await self.open_service_request(payload, prospect) + http_meta["retry_count"] = attempt + return response, http_meta + except SiebelClientError as siebel_err: + if not self._is_retryable(siebel_err): + raise + + if attempt == max_retries: + raise SiebelExceededRetriesError( + target_url, + max_retries, + siebel_err + ) + + wait = self._backoff(attempt) + logger.warning( + f"Transient error (attempt {attempt + 1}/{max_retries}), retrying {label} API call. Last error: {siebel_err}" + ) + await asyncio.sleep(wait) \ No newline at end of file diff --git a/src/components/clients/speech_analytics_client.py b/src/components/clients/speech_analytics_client.py new file mode 100644 index 0000000..f494dc6 --- /dev/null +++ b/src/components/clients/speech_analytics_client.py @@ -0,0 +1,332 @@ +""" +HTTP client for the Speech Analytics API. + +Responsibilities: +- OAuth2 authentication (client_credentials) with in-memory token caching + for the Prediction endpoint. +- Google service-account ID Token authentication (with in-memory caching) + for the History endpoint, which is exposed behind a private Cloud Run. +- POST /prediction-canais-recursais-api/predictions-canais-recursais + to obtain NLP insights for the current complaint. +- GET /v1/reclamacoes for complaint history. + +All errors are re-raised as SpeechClientError so that the caller +(speech_enrichment_node) can implement the graceful-degradation policy +without knowing the HTTP internals. +""" + +import os +import time +import asyncio +import logging +import httpx +from typing import Tuple, Dict, Any +from src.core.config import settings +from src.utils.observer import trace_tool +from src.utils.http import traced_async_client +from src.components.clients.exceptions.speech_exceptions import SpeechClientError + +logger = logging.getLogger(__name__) + +_PREDICTION_VARIABLES = [ + "RESUME", + "SUBMOTIVO", + "MOTIVO", + "SOLUCAO_PROPOSTA_CLIENTE", + "CAUSA_RAIZ", + "SENTIMENTO_CLIENTE", + "DESCORTESIA_CLIENTE", +] + +_OAUTH_PATH = "/oauth-admin/v1/oauth2/token" +_PREDICTION_PATH = "/prediction-canais-recursais-api/predictions-canais-recursais" +_HISTORY_PATH = "/v1/reclamacoes" + + +class SpeechAnalyticsClient: + """ + Thin async HTTP client for the Speech Analytics gateway. + + Token lifecycles: + - OAuth2 access token (Prediction API): cached at class level until 60s before expiry. + - Google ID Token (History API): cached at class level until 60s before expiry. + """ + + _cached_token: str | None = None + _token_expires_at: float = 0.0 + + _cached_history_id_token: str | None = None + _history_id_token_expires_at: float = 0.0 + _history_auth_unavailable: bool = False + + def __init__(self) -> None: + self.base_url = settings.SPEECH_PREDICTION_BASE_URL + + @trace_tool + async def _get_token(self) -> str: + """ + Return a valid OAuth2 Bearer token for the Prediction API, + refreshing it when necessary. + """ + if SpeechAnalyticsClient._cached_token and time.time() < SpeechAnalyticsClient._token_expires_at - 60: + return SpeechAnalyticsClient._cached_token + + base_url = settings.SPEECH_PREDICTION_BASE_URL + client_id = settings.SPEECH_PREDICTION_CLIENT_ID + client_secret = settings.SPEECH_PREDICTION_CLIENT_SECRET + + if not all([base_url, client_id, client_secret]): + raise SpeechClientError( + "Speech Prediction OAuth2 credentials not configured " + "(SPEECH_PREDICTION_BASE_URL, SPEECH_PREDICTION_CLIENT_ID, SPEECH_PREDICTION_CLIENT_SECRET)." + ) + + url = f"{base_url.rstrip('/')}{_OAUTH_PATH}" + logger.info("Requesting new Speech Prediction OAuth2 token.") + + def _sanitize_token_response(body): + if not isinstance(body, dict): + return body + return { + "api_product_list": body.get("api_product_list"), + "organization_name": body.get("organization_name"), + "developer.email": body.get("developer.email"), + "expires_in": body.get("expires_in"), + "status": body.get("status"), + } + + try: + async with traced_async_client( + timeout=settings.SPEECH_TIMEOUT, + response_sanitizer=_sanitize_token_response, + ) as client: + response = await client.post( + url, + headers={"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"}, + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }, + ) + except httpx.TimeoutException as exc: + raise SpeechClientError(f"Timeout during Speech Analytics authentication: {exc}") from exc + except httpx.RequestError as exc: + raise SpeechClientError(f"Network error during Speech Analytics authentication: {exc}") from exc + + if response.status_code != 200: + raise SpeechClientError( + f"Speech Analytics authentication failed: {response.text}", + status_code=response.status_code, + ) + + payload = response.json() + SpeechAnalyticsClient._cached_token = payload["access_token"] + expires_in = int(payload.get("expires_in", 3600)) + SpeechAnalyticsClient._token_expires_at = time.time() + expires_in + + logger.info("Speech Analytics token obtained. Expires in %d s.", expires_in) + return SpeechAnalyticsClient._cached_token + + @trace_tool + async def get_prediction( + self, + reclamacao_id: str, + raw_text: str, + customer_segment: str, + ) -> Tuple[dict, Dict[str, Any]]: + """ + Call the Prediction API and return the raw response dict with metadata. + + Returns: + Tuple of (response_body, http_meta) where: + - response_body: dict - The prediction API response. + - http_meta: Dict with keys url, status_code, response_text, latency_ms. + """ + token = await self._get_token() + base_url = settings.SPEECH_PREDICTION_BASE_URL + url = f"{base_url.rstrip('/')}{_PREDICTION_PATH}" + + body = { + "customer_segment": customer_segment, + "prediction_variables": _PREDICTION_VARIABLES, + "raw_text": raw_text, + "reclamacao_id": reclamacao_id, + } + + logger.info( + "Calling Speech Analytics Prediction API. reclamacao_id=%s customer_segment=%s", + reclamacao_id, + customer_segment, + ) + + start = time.perf_counter() + + try: + async with traced_async_client(timeout=settings.SPEECH_TIMEOUT) as client: + response = await client.post( + url, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + json=body, + ) + except httpx.TimeoutException as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + raise SpeechClientError(f"Timeout calling Speech Analytics Prediction: {exc}", url=url, latency_ms=latency_ms) from exc + except httpx.RequestError as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + raise SpeechClientError(f"Network error calling Speech Analytics Prediction: {exc}", url=url, latency_ms=latency_ms) from exc + + latency_ms = int((time.perf_counter() - start) * 1000) + + if response.status_code != 200: + raise SpeechClientError( + f"Speech Analytics Prediction API error: {response.text}", + status_code=response.status_code, + url=url, + response_text=response.text, + latency_ms=latency_ms, + ) + + http_meta = { + "url": url, + "status_code": response.status_code, + "response_text": response.text, + "latency_ms": latency_ms, + } + return response.json(), http_meta + + def _refresh_history_id_token(self) -> tuple[str, float]: + """ + Synchronously fetch a Google service-account ID Token for the + Speech History Cloud Run service. Returns (token, expires_at_epoch). + + Reads GOOGLE_APPLICATION_CREDENTIALS env var for the service-account + JSON file path, and uses SPEECH_HISTORY_AUDIENCE as target_audience. + """ + from google.oauth2 import service_account + import google.auth.transport.requests + + sa_path = os.getenv("GOOGLE_APPLICATION_CREDENTIALS") + audience = settings.SPEECH_HISTORY_AUDIENCE + + # Local development: return dummy token if file doesn't exist or DEBUG=True + if settings.DEBUG and (not sa_path or not os.path.exists(sa_path)): + logger.warning("Running in DEBUG mode — using dummy Google ID Token for Speech History API") + return "dummy-id-token-local-dev", time.time() + 3600 + + if not sa_path: + raise SpeechClientError( + "GOOGLE_APPLICATION_CREDENTIALS env var not set — " + "cannot authenticate to Speech History API." + ) + if not audience: + raise SpeechClientError( + "SPEECH_HISTORY_AUDIENCE not configured — " + "cannot mint Google ID Token for Speech History API." + ) + + creds = service_account.IDTokenCredentials.from_service_account_file( + sa_path, + target_audience=audience, + ) + creds.refresh(google.auth.transport.requests.Request()) + + # creds.expiry is a naive UTC datetime; convert to epoch seconds + expires_at = creds.expiry.timestamp() if creds.expiry else time.time() + 3600 + return creds.token, expires_at + + async def _get_history_id_token(self) -> str: + """Return a valid Google ID Token, refreshing it when needed.""" + if SpeechAnalyticsClient._history_auth_unavailable: + raise SpeechClientError( + "Speech History API unavailable: Google credentials not configured or invalid." + ) + + if ( + SpeechAnalyticsClient._cached_history_id_token + and time.time() < SpeechAnalyticsClient._history_id_token_expires_at - 60 + ): + return SpeechAnalyticsClient._cached_history_id_token + + logger.info("Requesting new Google ID Token for Speech History API.") + try: + token, expires_at = await asyncio.to_thread(self._refresh_history_id_token) + except SpeechClientError as exc: + SpeechAnalyticsClient._history_auth_unavailable = True + logger.warning("Speech History API will be skipped: %s", exc) + raise + SpeechAnalyticsClient._cached_history_id_token = token + SpeechAnalyticsClient._history_id_token_expires_at = expires_at + logger.info( + "Speech History ID Token obtained. Expires at epoch %.0f.", expires_at + ) + return token + + @trace_tool + async def get_history(self, cpf_cnpj: str, acesso_gsm: str | None = None) -> Tuple[list, Dict[str, Any]]: + """ + Call the complaint history API authenticated via Google service-account ID Token. + + Returns: + Tuple of (response_body, http_meta) where: + - response_body: list - The history API response. + - http_meta: Dict with keys url, status_code, response_text, latency_ms. + + Raises: + SpeechClientError: on auth failure, network error, or non-2xx HTTP response. + """ + base_url = settings.SPEECH_HISTORY_BASE_URL + if not base_url: + raise SpeechClientError( + "SPEECH_HISTORY_BASE_URL not configured — cannot call Speech History API." + ) + + url = f"{base_url.rstrip('/')}{_HISTORY_PATH}" + params = {"cpf_cnpj": cpf_cnpj} + if acesso_gsm: + params["acesso_gsm"] = acesso_gsm + + logger.info( + "Calling Speech Analytics History API. cpf_cnpj=%s acesso_gsm=%s", + cpf_cnpj, + acesso_gsm, + ) + + token = await self._get_history_id_token() + headers = {"Authorization": f"Bearer {token}"} + if settings.SPEECH_HISTORY_HOST: + headers["Host"] = settings.SPEECH_HISTORY_HOST + + start = time.perf_counter() + + try: + async with traced_async_client(timeout=settings.SPEECH_TIMEOUT) as client: + response = await client.get(url, params=params, headers=headers) + except httpx.TimeoutException as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + raise SpeechClientError(f"Timeout calling Speech Analytics History: {exc}", url=url, latency_ms=latency_ms) from exc + except httpx.RequestError as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + raise SpeechClientError(f"Network error calling Speech Analytics History: {exc}", url=url, latency_ms=latency_ms) from exc + + latency_ms = int((time.perf_counter() - start) * 1000) + + if response.status_code != 200: + raise SpeechClientError( + f"Speech Analytics History API error: {response.text}", + status_code=response.status_code, + url=url, + response_text=response.text, + latency_ms=latency_ms, + ) + + http_meta = { + "url": url, + "status_code": response.status_code, + "response_text": response.text, + "latency_ms": latency_ms, + } + return response.json(), http_meta diff --git a/src/components/clients/tais_kb_client.py b/src/components/clients/tais_kb_client.py new file mode 100644 index 0000000..7f4f30b --- /dev/null +++ b/src/components/clients/tais_kb_client.py @@ -0,0 +1,813 @@ +""" +HTTP/DB client for the TAIS knowledge base. + +Generates the query embedding via OCI GenAI (Cohere multilingual) and runs a +vector-similarity search against an Oracle Autonomous Database using the +native async oracledb driver. +""" + +import asyncio +import json +import logging +import time +import warnings +from enum import Enum +from typing import Any, Dict, Tuple + +import oci +import oci.exceptions +import oci.generative_ai_inference.models +import oci.retry +import oracledb + +from src.agent.local_prompts.preprocess_tais_kb_query import preprocess_tais_kb_query_pt +from src.agent.local_prompts.postprocess_tais_kb_query import postprocess_tais_kb_query_pt +from src.core.config import settings +from src.core.prompt_manager import get_prompt +from src.components.clients.exceptions.tais_kb_exceptions import TaisKbClientError +from src.providers.llm_provider import chat_llm_with_usage, classification_llm, tais_kb_llm +from src.utils.observer import trace_tool + +logger = logging.getLogger(__name__) + +# Return CLOBs as native strings instead of LOB handles to keep the search code simple. +oracledb.defaults.fetch_lobs = False + + +class Product(str, Enum): + """Supported TAIS products.""" + MOVEL = "Móvel" + FIBRA = "Fibra" + + +# Segments allowed per product +_ALLOWED_MOVEL_SEGMENTS: dict[str, list[str]] = { + "corporativo": ["SMB", "Top Clients", "M2M", "IoT"], + "pospago": ["Fatura", "Express"], + "controle": ["Fatura", "Express"], + "prepago": ["Pré-Pago"], + "beta": ["Beta"], + "fixogsm": ["Fixo (GSM)"], +} + +_ALLOWED_FIBRA_SEGMENTS: dict[str, list[str]] = { + "bandalarga": ["Banda Larga"], + "wttx": ["WTTX"], +} + + +class TaisKbClient: + """Async client for the TAIS knowledge base (Oracle ADB + OCI embeddings).""" + + _embed_client: oci.generative_ai_inference.GenerativeAiInferenceClient | None = None + + @classmethod + def _get_embed_client(cls) -> oci.generative_ai_inference.GenerativeAiInferenceClient: + if cls._embed_client is not None: + return cls._embed_client + + if not settings.TAIS_GENAI_ENDPOINT or not settings.TAIS_GENAI_COMPARTMENT_ID: + raise TaisKbClientError( + "TAIS GenAI not configured (TAIS_GENAI_ENDPOINT, TAIS_GENAI_COMPARTMENT_ID)." + ) + + import os + from agent_framework.config.settings import settings as fw_settings + + oci_config = oci.config.from_file( + os.path.expanduser(getattr(fw_settings, "OCI_CONFIG_FILE", "~/.oci/config")), + getattr(fw_settings, "OCI_PROFILE", None) or settings.OCI_CONFIG_PROFILE, + ) + if getattr(fw_settings, "OCI_REGION", None): + oci_config["region"] = fw_settings.OCI_REGION + + # OCI client treats `timeout` as a single int — tuple form is silently + # reduced to its first value. Use the configured TAIS timeout directly. + cls._embed_client = oci.generative_ai_inference.GenerativeAiInferenceClient( + config=oci_config, + service_endpoint=settings.TAIS_GENAI_ENDPOINT, + retry_strategy=oci.retry.NoneRetryStrategy(), + timeout=settings.TAIS_DB_TIMEOUT, + ) + return cls._embed_client + + async def _preprocess_query(self, query_text: str) -> str: + """ + Preprocess a query using LLM to optimize it for semantic search. + + Transforms the query into a semantically enriched form that maximizes + similarity for RAG and embedding-based retrieval. + + Args: + query_text: Original query text to preprocess + + Returns: + Reformulated query optimized for semantic search + + Raises: + TaisKbClientError: If LLM call fails + """ + llm = classification_llm + + # Get prompt from Langfuse with local fallback + prompt = get_prompt("preprocess_tais_kb_query_pt", preprocess_tais_kb_query_pt) + + # Format the message with the prompt and query + message = f"{prompt}\n\nTranscrição original:\n{query_text}" + + try: + # Call LLM to preprocess the query (com retry de JSON decode) + llm_resp = chat_llm_with_usage(llm, message, expect_json=True) + content = llm_resp.content + + logger.debug(f"Query preprocessing LLM response: {content}") + + parsed_response = llm_resp.parsed_json or {} + reformulated_query = parsed_response.get("reformulado", query_text) + + logger.info(f"Query preprocessed successfully. Original: {query_text[:100]}... -> Reformulated: {reformulated_query[:100]}...") + + return reformulated_query + + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse query preprocessing response as JSON: {e}. Returning original query.") + return query_text + except Exception as e: + logger.warning(f"Query preprocessing failed: {e}. Proceeding with original query.") + return query_text + + async def _postprocess_results(self, query_text: str, results: list[dict], reformulated_query: str | None = None) -> dict: + """ + Postprocess search results using LLM to synthesize an answer. + + Sends the query and top results to the LLM using the postprocess prompt, + which synthesizes a comprehensive answer based on the retrieved documents. + The LLM returns a JSON response with 'conteudo' (content) and 'id_procs' (document IDs). + + Args: + query_text: Original query text + results: List of search result documents with id_proc, title_proc, description_proc, content + reformulated_query: Query after preprocessing (optional) + + Returns: + dict with: + - content: Synthesized answer from the LLM (from JSON 'conteudo' field) + - id_procs: List of document IDs used in the answer (from JSON 'id_procs' field) + - filled_prompt: The complete prompt with all variables filled in + - postprocessing_succeeded: True on full success, False on JSON parse error + or LLM call failure (fail-open: caller still receives best-effort content) + - postprocessing_failure_reason: short label of the failure (only when failed) + - postprocessing_http_meta: url/status/response_text/latency_ms of the LLM call + (only when failed; consumed by AGA.036 emission downstream) + """ + llm = tais_kb_llm + + # Get prompt from Langfuse with local fallback + prompt = get_prompt("postprocess_tais_kb_query_pt", postprocess_tais_kb_query_pt) + + # Format documents for the prompt + formatted_docs = [] + for doc in results: + doc_text = f""" +Documento: {doc.get('id_proc', 'N/A')} +Título: {doc.get('title_proc', 'N/A')} +Descrição: {doc.get('description_proc', 'N/A')} +Conteúdo: {doc.get('content', 'N/A')} +""" + formatted_docs.append(doc_text) + + docs_context = "\n".join(formatted_docs) + pp_start = time.perf_counter() + + try: + # Build query context with both original and reformulated (if available) + query_context = f"Query: {query_text}" + + # Format the message with the prompt, query, and documents + filled_prompt = f"{prompt}\n\nPergunta do Operador:\n{query_context}\n\n---\n\nDocumentos de Referência:\n{docs_context}" + + # Call LLM to postprocess the results (com retry de JSON decode) + try: + llm_resp = chat_llm_with_usage(llm, filled_prompt, expect_json=True) + except json.JSONDecodeError as je: + # Esgotou as tentativas de obter JSON válido — fail-open com raw content vazio. + logger.warning(f"Failed to parse LLM JSON response after retries: {je}.") + return { + "content": "", + "id_procs": [], + "filled_prompt": filled_prompt, + "postprocessing_succeeded": False, + "postprocessing_failure_reason": "JSON parse error na resposta do LLM", + "postprocessing_http_meta": { + "url": settings.TAIS_GENAI_ENDPOINT or "N/A", + "status_code": 200, + "response_text": f"JSONDecodeError after retries: {str(je)[:200]}", + "latency_ms": int((time.perf_counter() - pp_start) * 1000), + }, + } + + content = llm_resp.content + logger.debug(f"Query postprocessing LLM response: {content[:200]}...") + + response_json = llm_resp.parsed_json or {} + postprocessing_content = response_json.get("conteudo", "") + postprocessing_id_procs = response_json.get("id_procs", []) + + logger.info(f"Results postprocessed successfully. Query: {query_text[:100]}... ID procs: {postprocessing_id_procs}") + + return { + "content": postprocessing_content, + "id_procs": postprocessing_id_procs if isinstance(postprocessing_id_procs, list) else [], + "filled_prompt": filled_prompt, + "postprocessing_succeeded": True, + } + + except Exception as e: + logger.warning(f"Results postprocessing failed: {e}. Returning empty result.") + return { + "content": "", + "id_procs": [], + "filled_prompt": "", + "postprocessing_succeeded": False, + "postprocessing_failure_reason": f"Falha na chamada do LLM ({type(e).__name__})", + "postprocessing_http_meta": { + "url": settings.TAIS_GENAI_ENDPOINT or "N/A", + "status_code": getattr(e, "status", "N/A"), + "response_text": str(e)[:500], + "latency_ms": int((time.perf_counter() - pp_start) * 1000), + }, + } + + def _embed_sync(self, text: str) -> tuple[list[float], int]: + client = self._get_embed_client() + embed_detail = oci.generative_ai_inference.models.EmbedTextDetails() + embed_detail.serving_mode = oci.generative_ai_inference.models.OnDemandServingMode( + model_id=settings.TAIS_GENAI_EMBED_MODEL_ID + ) + embed_detail.inputs = [text] + embed_detail.truncate = "NONE" + embed_detail.compartment_id = settings.TAIS_GENAI_COMPARTMENT_ID + embed_detail.input_type = "SEARCH_QUERY" + + endpoint = settings.TAIS_GENAI_ENDPOINT + start = time.perf_counter() + try: + response = client.embed_text(embed_detail) + except oci.exceptions.ServiceError as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + raise TaisKbClientError( + f"OCI embedding service error: {exc}", + status_code=exc.status, + url=endpoint, + response_text=str(getattr(exc, "message", exc)), + latency_ms=latency_ms, + ) from exc + except (oci.exceptions.RequestException, oci.exceptions.ConnectTimeout) as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + raise TaisKbClientError( + f"OCI embedding request error: {exc}", + url=endpoint, + response_text=str(exc), + latency_ms=latency_ms, + ) from exc + + return response.data._embeddings[0], response.status + + async def _embed(self, text: str) -> tuple[list[float], int]: + # OCI Python SDK has no async client; isolate the blocking call. + return await asyncio.to_thread(self._embed_sync, text) + + @staticmethod + def _validate_product(product: Product | None) -> None: + """Validate that product is a valid Product enum value.""" + if product is None: + raise ValueError( + f"product is required. Valid values: Product.MOVEL, Product.FIBRA" + ) + if not isinstance(product, Product): + valid_values = [p.name for p in Product] + raise TypeError( + f"product must be a Product enum value (e.g. Product.MOVEL), got {type(product).__name__}: {product!r}. " + f"Valid values: {valid_values}" + ) + + @staticmethod + def _validate_segments(segments: list[str], product: Product) -> None: + """Validate segments are known and allowed for the product.""" + # Get allowed mapping for product + if product == Product.MOVEL: + allowed_mapping = _ALLOWED_MOVEL_SEGMENTS + elif product == Product.FIBRA: + allowed_mapping = _ALLOWED_FIBRA_SEGMENTS + else: + raise ValueError(f"Unknown product: {product}") + + allowed_segments = set(allowed_mapping.keys()) + + for seg in segments: + seg_lower = seg.lower() + if seg_lower not in allowed_segments: + raise ValueError( + f"Unrecognized segment '{seg}' for product {product.value}. " + f"Allowed values: {sorted(allowed_segments)}" + ) + + @staticmethod + def _validate_sub_segments_for_segments( + segments: list[str], + sub_segments: list[str], + product: Product + ) -> None: + """Validate that sub_segments are allowed for the given segments.""" + if not sub_segments: + return # No sub_segments to validate + + # Get allowed mapping for product + if product == Product.MOVEL: + allowed_mapping = _ALLOWED_MOVEL_SEGMENTS + elif product == Product.FIBRA: + allowed_mapping = _ALLOWED_FIBRA_SEGMENTS + else: + raise ValueError(f"Unknown product: {product}") + + # Build set of allowed sub_segments for given segments + allowed_for_segments: set[str] = set() + for seg in segments: + seg_lower = seg.lower() + if seg_lower in allowed_mapping: + allowed_for_segments.update(allowed_mapping[seg_lower]) + + # Validate each sub_segment is allowed and is case-insensitive match + for sub_seg in sub_segments: + sub_seg_lower = sub_seg.lower() + + # Check if it exists in allowed set (case-insensitive) + found = False + for allowed_sub in allowed_for_segments: + if allowed_sub.lower() == sub_seg_lower: + found = True + break + + if not found: + raise ValueError( + f"Sub-segment '{sub_seg}' is not allowed for segments {segments}. " + f"Allowed sub-segments: {sorted(allowed_for_segments)}" + ) + + @staticmethod + def _fill_sql_with_bind_params(sql: str, bind_params: dict[str, object]) -> str: + """Replace bind parameters in SQL with their actual values (for debugging). + + Note: Vectors are not filled for readability. + """ + filled_sql = sql + + for key, value in bind_params.items(): + if key == "query_embedding": + # Skip vectors for readability + filled_sql = filled_sql.replace(f":{key}", "[VECTOR]") + elif isinstance(value, str): + # Escape single quotes in strings + escaped_val = value.replace("'", "''") + filled_sql = filled_sql.replace(f":{key}", f"'{escaped_val}'") + elif isinstance(value, (int, float)): + filled_sql = filled_sql.replace(f":{key}", str(value)) + elif value is None: + filled_sql = filled_sql.replace(f":{key}", "NULL") + else: + filled_sql = filled_sql.replace(f":{key}", f"'{str(value)}'") + + return filled_sql + + @trace_tool + async def search_documents( + self, + query_text: str, + product: Product | None = None, + segments: list[str] | None = None, + sub_segments: list[str] | None = None, + top_k: int = 3, + check_expiration_date: bool = True, + fetch_limit_multiplier: int = 100, + preprocess: bool = True, + postprocess: bool = True, + deduplicate: bool = False, + telemetry_top_n: int = 20, + ) -> dict[str, Any]: + """Search TAIS knowledge base using vector similarity with product-based filtering. + + Args: + query_text: Query text to embed and search + product: Product to search within (MOVEL or FIBRA); required + segments: List of segments to filter by (product-specific); optional + sub_segments: List of sub-segments to filter by; optional + top_k: Number of unique results to return (default 3) + check_expiration_date: Whether to exclude expired documents (default True) + fetch_limit_multiplier: Multiplier for database fetch limit (default 100) + 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: + dict with 'sql' (filled SQL for debugging), 'results' (unique records), 'reformulated_query', 'postprocessing', 'postprocessing_prompt' + + Raises: + TaisKbClientError: If DB not configured or OCI embedding fails + ValueError: If product is None, segment is invalid, or sub-segment validation fails + TypeError: If product is not a Product enum + NotImplementedError: If product is FIBRA (not yet implemented) + """ + if top_k < 1: + raise ValueError("top_k must be at least 1.") + + # Validate product parameter + self._validate_product(product) + + if product == Product.FIBRA: + raise NotImplementedError("Fibra product support is not yet implemented.") + + if not all([settings.MONGODB_DB_USER, settings.MONGODB_DB_PASSWORD, settings.TAIS_DB_DSN]): + raise TaisKbClientError( + "TAIS DB not configured (MONGODB_DB_USER, MONGODB_DB_PASSWORD, TAIS_DB_DSN).", + url="N/A", + ) + + # Normalize to mutable lists + active_segments: list[str] = list(segments) if segments else [] + active_sub_segments: list[str] = list(sub_segments) if sub_segments else [] + + # Validate inputs + if active_segments: + self._validate_segments(active_segments, product) + + if active_sub_segments: + # If we have sub_segments, we need at least one segment to validate against + if not active_segments: + raise ValueError( + "sub_segments requires at least one segment to validate against" + ) + self._validate_sub_segments_for_segments( + active_segments, active_sub_segments, product + ) + + # Product-based segment injection + if product == Product.MOVEL: + if "todosossegmentosmovel" not in [s.lower() for s in active_segments]: + active_segments.append("todosossegmentosmovel") + elif product == Product.FIBRA: + if "todosossegmentosultrafibra" not in [s.lower() for s in active_segments]: + active_segments.append("todosossegmentosultrafibra") + + # Track original query and reformulated query + original_query = query_text + reformulated_query: str | None = None + + if preprocess: + # Preprocess the query with OCI GenAI (e.g. for better embedding quality) + try: + reformulated_query = await self._preprocess_query(original_query) + query_text = reformulated_query + except Exception as e: + logger.warning(f"Query preprocessing failed, proceeding with original query. Error: {e}") + reformulated_query = None + + embed_start = time.perf_counter() + embedding, embed_status = await self._embed(query_text) + embedding_str = json.dumps(embedding) + fetch_limit = max(top_k, 1) * fetch_limit_multiplier + + bind_params: dict[str, object] = { + "query_embedding": embedding_str, + "fetch_limit": fetch_limit, + } + + conditions: list[str] = [] + + # Build segment filter: segments come as "controle + pospago + beta" + # We need to find if ANY of our segments match ANY of the delimited values + if active_segments: + segment_conditions = [] + for i, seg in enumerate(active_segments): + key = f"seg_{i}" + # Search for " seg " or "seg " or " seg" to handle word boundaries with "+" + # Oracle INSTR is case-insensitive by default when using LOWER() + segment_conditions.append( + f"INSTR(' ' || REPLACE(LOWER(segment), ' + ', ' ') || ' ', ' ' || LOWER(:{key}) || ' ') > 0" + ) + bind_params[key] = seg.lower() + conditions.append(f"({' OR '.join(segment_conditions)})") + + # Build sub_segment filter: sub_segments come as "sub1, sub2, sub3" + # We need to find if ANY of our sub_segments match ANY of the delimited values + if active_sub_segments: + sub_segment_conditions = [] + for i, sub_seg in enumerate(active_sub_segments): + key = f"sub_seg_{i}" + # Search for ",sub," or at start/end to handle word boundaries with "," + # Match case-insensitively + sub_segment_conditions.append( + f"INSTR(',' || LOWER(REPLACE(sub_segments, ' ', '')) || ',', ',' || LOWER(:{key}) || ',') > 0" + ) + bind_params[key] = sub_seg.lower().replace(" ", "") + conditions.append(f"({' OR '.join(sub_segment_conditions)})") + + # ── expiration filter ───────────────────────────────────────────── + if check_expiration_date: + # TRUNC(SYSDATE) strips the time component from the current server + # date, ensuring a clean date-only comparison against expiration_date. + conditions.append("(expiration_date IS NULL OR expiration_date >= TRUNC(SYSDATE))") + + # ── distance filter ─────────────────────────────────────────────── + # Only return results with cosine distance lower than 0.5 + conditions.append("VECTOR_DISTANCE(embedding, TO_VECTOR(:query_embedding), COSINE) < 0.5") + + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" + + sql = f""" + SELECT + id, + doc_name, + id_proc, + CAST(title_proc AS VARCHAR2(4000)) AS title_proc, + description_proc, + updated_proc, + segments, + content, + created_at, + updated_at, + uuid, + subject, + consultant_segments, + expiration_date, + sub_segments, + segment, + VECTOR_DISTANCE(embedding, TO_VECTOR(:query_embedding), COSINE) AS distance + FROM {settings.TAIS_TABLE_CHUNKS} + {where_clause} + ORDER BY distance ASC + FETCH FIRST :fetch_limit ROWS ONLY + """ + + start = time.perf_counter() + try: + async with oracledb.connect_async( + user=settings.MONGODB_DB_USER, + password=settings.MONGODB_DB_PASSWORD, + dsn=settings.TAIS_DB_DSN, + tcp_connect_timeout=settings.TAIS_DB_TIMEOUT, + ) as conn: + async with conn.cursor() as cur: + await cur.execute(sql, bind_params) + rows = await cur.fetchall() + cols = [c[0].lower() for c in cur.description] + except oracledb.Error as exc: + latency_ms = int((time.perf_counter() - start) * 1000) + raise TaisKbClientError( + f"TAIS DB error: {exc}", + url=settings.TAIS_GENAI_ENDPOINT or "TAIS_DB", + response_text=str(exc), + latency_ms=latency_ms, + ) from exc + + latency_ms = int((time.perf_counter() - start) * 1000) + records = [dict(zip(cols, row)) for row in rows] + + # Apply deduplication if enabled + if deduplicate: + seen: set[str] = set() + unique: list[dict] = [] + for r in records: + title = r.get("title_proc") + if not title or title in seen: + continue + seen.add(title) + unique.append(r) + if len(unique) >= top_k: + break + else: + # No deduplication: just take the first top_k records + unique = records[:top_k] + + # Return with filled SQL for debugging (except vector value) + filled_sql = self._fill_sql_with_bind_params(sql, bind_params) + + # Postprocess results if enabled + postprocessing_content = None + postprocessing_id_procs = None + postprocessing_id_procs_map = None # Map of id_proc -> title_proc + postprocessing_prompt = None + postprocessing_succeeded = None # None when postprocess skipped + postprocessing_failure_reason = None + postprocessing_http_meta = None + if postprocess and unique: + try: + postprocessing_response = await self._postprocess_results( + query_text, + unique, + reformulated_query=reformulated_query + ) + postprocessing_content = postprocessing_response.get("content") + postprocessing_id_procs = postprocessing_response.get("id_procs") + postprocessing_prompt = postprocessing_response.get("filled_prompt") + postprocessing_succeeded = postprocessing_response.get("postprocessing_succeeded") + postprocessing_failure_reason = postprocessing_response.get("postprocessing_failure_reason") + postprocessing_http_meta = postprocessing_response.get("postprocessing_http_meta") + + # Build mapping of id_proc -> title_proc for the returned id_procs + if postprocessing_id_procs: + postprocessing_id_procs_map = {} + for doc in unique: + doc_id = doc.get("id_proc") + if doc_id in postprocessing_id_procs: + postprocessing_id_procs_map[doc_id] = doc.get("title_proc", "") + + logger.debug(f"Results postprocessed successfully. Length: {len(postprocessing_content) if postprocessing_content else 0}") + except Exception as e: + logger.warning(f"Results postprocessing failed: {e}. Continuing without postprocessing.") + postprocessing_succeeded = False + postprocessing_failure_reason = f"Exception inesperada no postprocess ({type(e).__name__})" + postprocessing_http_meta = { + "url": settings.TAIS_GENAI_ENDPOINT or "N/A", + "status_code": getattr(e, "status", "N/A"), + "response_text": str(e)[:500], + "latency_ms": 0, + } + + logger.info( + "TAIS KB search completed. raw=%d unique=%d top_k=%d product=%s segments=%s sub_segments=%s check_expiration=%s deduplicate=%s postprocessing=%s", + len(records), len(unique), top_k, product.name if product else None, + active_segments, active_sub_segments, check_expiration_date, deduplicate, bool(postprocessing_content), + ) + + http_meta = { + "url": settings.TAIS_GENAI_ENDPOINT or "N/A", + "status_code": embed_status, + "response_text": f"raw_records={len(records)} unique={len(unique)}", + "latency_ms": int((time.perf_counter() - embed_start) * 1000), + } + + # Build a slim "retrieved" list for telemetry (AGAs that carry + # ragRetrievedDocuments). Capped at `telemetry_top_n` to keep IC + # payload size sane — `records` may have hundreds of candidates. + # Already ordered by distance ASC from the SQL. Chunks/content are + # dropped here because consumers of `retrieved_documents` only need + # the doc identifier for observability. + retrieved_documents = [ + { + "documentId": r.get("id_proc"), + "title": r.get("title_proc"), + "distance": float(r["distance"]) if r.get("distance") is not None else None, + } + for r in records[:telemetry_top_n] + ] + + return { + "sql": filled_sql, + "results": unique, + "retrieved_documents": retrieved_documents, + "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, + "postprocessing_succeeded": postprocessing_succeeded, + "postprocessing_failure_reason": postprocessing_failure_reason, + "postprocessing_http_meta": postprocessing_http_meta, + "http_meta": http_meta, + } + + @trace_tool + async def search_documents_legacy( + self, + query_text: str, + top_k: int, + segment_filter: str | None = None, + ) -> tuple[dict[str, Any], dict[str, Any]]: + """DEPRECATED: Use search_documents() instead with product and segments parameters. + + Legacy API wrapper that maps old segment_filter to new product-based approach. + This method maintains backward compatibility with existing code. + + Returns a tuple (response_dict, http_meta_dict) following the same pattern as + SiebelClient.open_service_request_with_retry and ImdbClient.get_imdb_access_data_with_retry. + """ + warnings.warn( + "search_documents_legacy() is deprecated. Use search_documents(query_text, product=Product.MOVEL, ...) instead.", + DeprecationWarning, + stacklevel=2 + ) + + # Map legacy segment_filter to new API + # For backward compatibility, we resolve segments using a simple mapping + segments = None + if segment_filter: + key = segment_filter.strip().lower() + # Try to map old segment names to new allowed segments + # For simplicity, if we can find a matching key in MOVEL segments, use it + if key in _ALLOWED_MOVEL_SEGMENTS: + segments = [key] + + response = await self.search_documents( + query_text=query_text, + product=Product.MOVEL, + segments=segments, + top_k=top_k, + check_expiration_date=True, + fetch_limit_multiplier=50, # Keep old fetch limit for backward compatibility + ) + http_meta = response.pop("http_meta", { + "url": settings.TAIS_GENAI_ENDPOINT or "N/A", + "status_code": "N/A", + "response_text": "N/A", + "latency_ms": 0, + }) + return response, http_meta + + @trace_tool + async def get_content_by_id_proc( + self, + id_proc: str, + return_as: str = "html" + ) -> dict[str, Any]: + """Fetch document content from TAIS_BASE_CHUNKS by id_proc with format conversion. + + Args: + id_proc: Procedure ID to search for + return_as: Format to return (html or markdown), case insensitive + + Returns: + dict with 'sql' (filled SQL for debugging), 'results' (records), 'return_as' (format) + + Raises: + TaisKbClientError: If DB not configured + ValueError: If id_proc is empty or return_as is invalid + """ + if not all([settings.MONGODB_DB_USER, settings.MONGODB_DB_PASSWORD, settings.TAIS_DB_DSN]): + raise TaisKbClientError( + "TAIS DB not configured (MONGODB_DB_USER, MONGODB_DB_PASSWORD, TAIS_DB_DSN)." + ) + + if not id_proc or not id_proc.strip(): + raise ValueError("id_proc cannot be empty.") + + # Normalize return_as to lowercase + return_as_lower = return_as.strip().lower() + if return_as_lower not in ("html", "markdown"): + raise ValueError(f"return_as must be 'html' or 'markdown', got '{return_as}'") + + # Build SELECT clause based on return_as + content_column = "content_html" if return_as_lower == "html" else "content_markdown" + + sql = f""" + SELECT + id, + doc_name, + id_proc, + title_proc, + description_proc, + updated_proc, + segments, + {content_column} AS {content_column}, + created_at, + updated_at, + uuid, + subject, + consultant_segments, + expiration_date, + sub_segments, + segment + FROM {settings.TAIS_TABLE_FILES} + WHERE id_proc = :id_proc + """ + + bind_params: dict[str, object] = { + "id_proc": id_proc.strip(), + } + + try: + async with oracledb.connect_async( + user=settings.MONGODB_DB_USER, + password=settings.MONGODB_DB_PASSWORD, + dsn=settings.TAIS_DB_DSN, + tcp_connect_timeout=settings.TAIS_DB_TIMEOUT, + ) as conn: + async with conn.cursor() as cur: + await cur.execute(sql, bind_params) + rows = await cur.fetchall() + cols = [c[0].lower() for c in cur.description] + except oracledb.Error as exc: + raise TaisKbClientError(f"TAIS DB error: {exc}") from exc + + records = [dict(zip(cols, row)) for row in rows] + + # Fill the SQL with bind params for debugging (except for LOB fields) + filled_sql = self._fill_sql_with_bind_params(sql, bind_params) + + logger.info( + "TAIS KB get_content_by_id_proc completed. id_proc=%s return_as=%s results=%d", + id_proc, return_as_lower, len(records), + ) + return { + "sql": filled_sql, + "results": records, + "return_as": return_as_lower + } diff --git a/src/components/tools/__init__.py b/src/components/tools/__init__.py new file mode 100644 index 0000000..0b7c3f5 --- /dev/null +++ b/src/components/tools/__init__.py @@ -0,0 +1,10 @@ +""" +Custom tools for the agent. + +This module contains custom tool implementations that can be used +by the agent to perform specific tasks. +""" + +from src.components.tools.example_tool import ExampleTool + +__all__ = ["ExampleTool"] diff --git a/src/components/tools/__pycache__/__init__.cpython-313.pyc b/src/components/tools/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..05cfa1c2b19b5df9335888ef9531155c99ea67b8 GIT binary patch literal 404 zcmYLFKT88K6i@D2TCUVl#OZbHasfvX7jaQ=x$Ys&<=W^aIg%7wH@AKPKZKvd$**ve zP#oNp;%?F^eURk6y!ZQ)Y=^@Pg?N1%-G7906N~>y{*vu!NS;xFEGlr3+~S+WCV+~R zV4Kc5<L=`+yIMGJ6nJB52bHnl=K|PFXrE`<WG)>jQ!Z)&+-T3FcEAq^kgBPL3d77x z6MBBmJaDFADq!J6nN3#*!XN^Tut7wDYXp~-<lvcmaC!C_Est?KxL&fzISGDj8t(4K zowi45X*i|r$XT9qqZ*@wrptG(bFqieSnV(o!0MXP7qpGM?HN(plahHxD!HV*sYqZV z2PU#(B9uxkJ*9cGYWo-ahhN(G(Ksx39Ja3%WBi?Tap&g<;q&*w$(lU%Y(KPq0U^tJ A%m4rY literal 0 HcmV?d00001 diff --git a/src/components/tools/__pycache__/example_tool.cpython-313.pyc b/src/components/tools/__pycache__/example_tool.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..644590a693c516949c1e4d0b8252288a88c89a7b GIT binary patch literal 3329 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 O%K7!FKM20%hW`MOkgRL~ literal 0 HcmV?d00001 diff --git a/src/components/tools/example_tool.py b/src/components/tools/example_tool.py new file mode 100644 index 0000000..e92ec79 --- /dev/null +++ b/src/components/tools/example_tool.py @@ -0,0 +1,98 @@ +""" +Example custom tool implementation using LangChain BaseTool. + +This module demonstrates how to create a custom tool that can be used +by the agent. The tool includes both synchronous and asynchronous methods. +""" + +from typing import Optional, Type +from langchain_core.tools import BaseTool +from pydantic import BaseModel, Field + + +class ExampleToolInput(BaseModel): + """Input schema for ExampleTool.""" + + query: str = Field( + ..., + description="The query string to process" + ) + uppercase: bool = Field( + default=False, + description="Whether to convert the result to uppercase" + ) + + +class ExampleTool(BaseTool): + """ + Example tool that processes a query string. + + This tool demonstrates: + - Custom input schema with Pydantic + - Synchronous execution with _run + - Asynchronous execution with _arun + - Proper type hints and documentation + + Example: + >>> tool = ExampleTool() + >>> result = tool._run(query="hello world", uppercase=True) + >>> print(result) + 'Processed: HELLO WORLD' + """ + + name: str = "example_tool" + description: str = ( + "A tool that processes text queries. " + "Useful for demonstrating custom tool implementation. " + "Input should be a query string." + ) + args_schema: Type[BaseModel] = ExampleToolInput + + def _run( + self, + query: str, + uppercase: bool = False, + run_manager: Optional[any] = None + ) -> str: + """ + Execute the tool synchronously. + + Args: + query: The query string to process + uppercase: Whether to convert result to uppercase + run_manager: Optional callback manager for the run + + Returns: + Processed query string with prefix + """ + result = f"Processed: {query}" + + if uppercase: + result = result.upper() + + return result + + async def _arun( + self, + query: str, + uppercase: bool = False, + run_manager: Optional[any] = None + ) -> str: + """ + Execute the tool asynchronously. + + This is the async version of _run. In this simple example, + it just calls the sync version, but in real implementations + you would use async operations (e.g., async HTTP calls, async DB queries). + + Args: + query: The query string to process + uppercase: Whether to convert result to uppercase + run_manager: Optional async callback manager for the run + + Returns: + Processed query string with prefix + """ + # In a real implementation, you would use async operations here + # For example: await async_http_call(query) + return self._run(query, uppercase, run_manager) diff --git a/src/core/.DS_Store b/src/core/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..f24afa48cc96c7c503e3dd32bad75876f7643493 GIT binary patch literal 6148 zcmeHKJ5B>Z41E(KlqgL~%DF;%iX~c3kcw)_PYF_%UGXXC_G~oVgN|G9JhlQ$J`xRz zkS%%MtjC`5zKPZ`fG~bujDR749#yc@=ded)U35n}W|75^_LyUeIo4RB*ztD5DKa2; zH{fj*sPV$P`?|kkvl>n2RatA=@2`hF%PP-jn`%Z6*>8FI@pg60d+`hJdi^bn5V<Y- zc*GMP_$!;D#2xOjYUg@>z2ECco;Q}~p8d+ImE#f5R(?qNNscbgfHU9>oH7Hf*&@Ax zqL0piGvEwt8IbQoqzYz+jbi$Aa7Zlxu}gCj&ZU=-m}r<8Hi}%K1Pvu>Xs{L|XgKZB z{4&EvQNt0^$=t_I4%VTBbUN)Zxg%tXJ~{)=K%0RxeH=>tXUBA?|8EERlQZB9{3`}R zH=pEVzLM3}otKkZ8>zQc5%Fsjw;`OQQcPbd#TV2hv`3i`Gs8xa7K;B7NHqB14E!kr EpMq~vfB*mh literal 0 HcmV?d00001 diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..080e713 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1,41 @@ +"""Infrastructure Layer - Configuration, logging, exceptions.""" + +from src.core.config import Settings, LLMConfig, AgentConfig, settings +from src.core.exceptions import ( + AgentException, + ConfigurationError, + LLMError, + ToolExecutionError +) +from src.core.logging import ( + setup_logging, + setup_logging_from_settings, + get_logger, + set_request_id, + set_session_id, + set_user_id, + clear_context, + MetricsLogger, +) + +__all__ = [ + # Config + "Settings", + "LLMConfig", + "AgentConfig", + "settings", + # Exceptions + "AgentException", + "ConfigurationError", + "LLMError", + "ToolExecutionError", + # Logging + "setup_logging", + "setup_logging_from_settings", + "get_logger", + "set_request_id", + "set_session_id", + "set_user_id", + "clear_context", + "MetricsLogger", +] diff --git a/src/core/__pycache__/__init__.cpython-313.pyc b/src/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a1bc526ed2eb614b8f2359ccf24dfd6e8c98cbdd GIT binary patch literal 875 zcma)(OK;Oa5XaZf%Xv7@f`odpxJ4?l)FVQO3Z+svNd*c=q?Orhj9bSWX4jB%<3sQv z_&l80D+k04kwdTS<k6~953J;$W@cw+{+?O;evR03eDe6>yg<lLboR&H8SD4f_)4Y( z$kds-BZs=+(gGA{5sI_~r2ylFQJGetLOt+k6{@rbwN1P@s?!EEs1H7ELX)<jMcdG( z9q7<5bZHNI0SU>p^xA=gxZJONetMS8AyOK0u5%E<IJy!LJPBUPY@Wn9L^_e#QIN_w zPO|tY5GyX07*m7aD7jxW-c)F9FO{i|$CK?tQ$LACrgx5~b{RIixw#nb%b4z+PD20* z#<O*{R`-pR>2M`@jy*TAzGr+}FJH2qD06tnWpj`Vw(n?avCx}L0j6oA48(_AD9w`D z)>lF)TO0j4-segHIzCTD1dPi}i<LJ1L}*C38rw+-=(oXckU$YpLX;5|gomgiaNSTx zG!Q<biD)6(hz_D_ao&GmDvZq}XUwz|@Bx<~EN~C^H8Z`Nq}!$3r@*vt5_c2(Uri_| zN>j$Zk+qdb%-e90>2MZKrf0z`kQah$6-KyeJN0NGK1sM_k|*K2h+oS2JmCTd_)&mP zX7V;Cw2DIfzmTycOEhDH<(2WCE#xdu#dA2c%^drwfyJMK<2XOai(lmQJ2|~}OOCrH X7T1lc1+iEo{ysdYyI(5s$UgZ8#Blhz literal 0 HcmV?d00001 diff --git a/src/core/__pycache__/config.cpython-313.pyc b/src/core/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9ba030d638562bb39746d7c6989f0f92c5b2bf98 GIT binary patch literal 24752 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 literal 0 HcmV?d00001 diff --git a/src/core/__pycache__/exceptions.cpython-313.pyc b/src/core/__pycache__/exceptions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e05f811ec5de3abc60ec78e492d565bf0f85a33 GIT binary patch literal 2373 zcmb_dQEwYX5Z=8z+ZQJ(X+%kAAlin~V3fwFO_7jFphZCx+9;9}sff_i<!<e4a=tUO zcTK|s>JtKq2VN@WCy;*w2&7{1##7#k?2tY(vzORuA|XUs>E_0>yR$Rj_uaZPJX~ft zuH2Z~n06TZgMJ*1LhkTRgw7*YW58;>W_`jxw!ji>oK3NseV)~vRr{KquLZ|M3m7e~ zI=yJYL`xWTk41|nI)Ksgv1q9=SQ+wT&e%;q*i7nm70Am`68Mpj4+7arRT7IJX}0`S zt*J<*+amI}CCrw~%j;4E8Gwva;WyBFb?}>VD}hbX!ekf|nU<kg+ZO4%(xL^)h73{> zCQ^$yN%4eMqGh?it$$=Q3A0Gf3PGo=^-9CNO<%=gQ_7YWD%B#{ip%|NLF8+V+5I@g zGo|>@*XfO=TcWALFp^t7NU@G{5iY^$rZAmG+cRQoT}6^&Wys=GH6<mNQC;X%MG^I3 zc8Gqh^#la<T91;g8PUjm2*Fp8o)H^a*pParoUCaH_a&HWZr{E`0Nhuh1U*~cBL*wH zJ?O`X1cIByI>=egEy{xi@64gShk{sTz)=)dje}jYz^N5##T7QkNfaCIhO@z;P;&rd z?{X+zWR+6;%nhPt;fTk*VNCVq+)ZCg;kR0m3e363h~84^I#w$_T3%kl&iypgNPZ}1 zgG0qpNsN4ac;`Ag^ZXI}jG|^N+bisIRPqYHhLWNo*xixH%6-P}il~>Vf&DL5oOVHH zEeUPc^Hi)-&uf>OQfpLCdoVA-qY`kTLxcc*K`@A>J>YqUZ_m?&mJs4$`(Cvfr`51} zr*=y$<$BZA6YBJmKvnyi!t^n+l&Uc4E4Zp5s0InhYEN8xwzUldbnw`-xXvC`K7x~a z0?pHh?00wg+tN?&_|Dib?uEx=liv+Jp1Aa@JKn)*R<ZhRRQO&Ov8p&G)V6gBd3&~s z(~3*D#Q0euE@jWlKMVASJnxIlj}B*E@VvSLokl8_aY7J!UYG=)m)Cm^5h9HRo}Z?m z*0NM;Tr_-$Um&B%^L9CqJ~Z+6KAb@dbR`*MCaAloU$ftw`2)u;Odl{bKtZ!4K&Lkj zIp&=i44o3MV`OXPvPB`=P%xq`qG${4(GQZit{NE_aV`KPu=f(C33?*JA5@u<;1oJ1 zBxoH;kgm^nEWuD{_9C3cmDnRGXV(H3Uk03A8-~a?vNC;A@hGJn0>k&j>5_V!kKyXh zIW*63{zLGiJN~nK#o)6`2b%n&xNf%uFX9Vm4^U%3DNHT!GI{68kcy5|@hSz$(2fq( ziBud|yl{1&p~-om`2e5ZpjL4FCmvk9v2ygOmghl#*IrbAp{zh)BDg??Xw#>GK6`X# z58hX3^+hs9X|I!aiOe*alPJAOK{7M}Z;&zDDD=MpGrF84(ZT~6WOQri5D7ZqIRpxq zsn&mjX5#dzJ&?@4e+|-GbQBpuP9G!+Emm9@$2Z+I^(@T*?;RkX+5^46k^EXPf;4!W ze1@12N=P?H+r6IORP*pQ{^a=R_s~4CIp=>`CGPBxGX7e(Sme{+f7)gE>W*6c>ds`B U;j3Fl^UYU%e`j)^;)cw>0lsn|h5!Hn literal 0 HcmV?d00001 diff --git a/src/core/__pycache__/logging.cpython-313.pyc b/src/core/__pycache__/logging.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e94d3de59b2943b90a5e14c889ef6a04fa1f583e GIT binary patch literal 25958 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 XZPL7`dB{=x*|4U}qWz|k&6ocVTqQX& literal 0 HcmV?d00001 diff --git a/src/core/__pycache__/prompt_manager.cpython-313.pyc b/src/core/__pycache__/prompt_manager.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..97f973fa3b4296e1f94815b72cc41ecca4785bdc GIT binary patch literal 3523 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 literal 0 HcmV?d00001 diff --git a/src/core/__pycache__/state_logger.cpython-313.pyc b/src/core/__pycache__/state_logger.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ea466e707ba758f1eae9b89bdb8c43a485b9135e GIT binary patch literal 569 zcmYjOJ!>055S_j66X+x0A_$yZf(W@3x-<sc#4cR8QBa{nl?BIfS{-MsJF&Mia%Wfm z1%h-*o4?}HDI(TQAZgMm7VbT(I}r`+%)Hr|H*eWhwORqR-*<1$_XtJ_Hl~)z>JXDT z_~4TcFc?5j*|hwRZ(*QF25C(7RbOesIbm8%T<xzccB2iSL?#}ISEuOaaE89H-gZ}6 z^GP52q^}&2_hn^>3LN+dzS72B2Z{qRxLQ{<avpLTa2BR{!jmj@RZ*KTdYNR?JXl|f zYBXhxro4pKnDTHK^3Wwhr!yRN6=7UtaZH)el5~_Ihqd7lW;xB|3vwyeaUAi%8>ifp zEZ!)Y1<_>W<t*}|j8U(UR1mf&S7Ha#V)vF#RzoI3QpzV@!ym13+qvm1tmdy*?-skQ z#Y^XF?c2#eQ>#~&I_Ql@Fm+>vz2K6rs|!;uNYYr?G3B2N^;Qt1;g|*iv$1cz;ybXq z%&X{x^=X_9rx)~??I4t&m%m20R0$yuP+J;MdGXV3-rLPD`#<c~weh6c#8?wN12eLQ AUH||9 literal 0 HcmV?d00001 diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..91209e5 --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,370 @@ +""" +Configuration management module using Pydantic Settings. + +This module provides configuration loading from environment variables and YAML files, +with validation of required fields. +""" + +from typing import Optional, List +from pathlib import Path +from pydantic import BaseModel, Field, field_validator, AliasChoices +from pydantic_settings import BaseSettings, SettingsConfigDict +import yaml +import os +from dotenv import load_dotenv + +_PROJECT_ROOT = Path(__file__).resolve().parents[2] +load_dotenv(_PROJECT_ROOT / ".env", override=False) + + +class LLMConfig(BaseModel): + """LLM configuration model.""" + + provider: str = Field(..., description="LLM provider (openai, anthropic, etc.)") + model: str = Field(..., description="Model name") + temperature: float = Field(0.7, ge=0.0, le=2.0, description="Temperature for generation") + max_tokens: Optional[int] = Field(None, gt=0, description="Maximum tokens to generate") + + @field_validator('provider') + @classmethod + def validate_provider(cls, v: str) -> str: + """Validate that provider is supported.""" + supported = ['openai', 'anthropic', 'oci_openai', 'oci', 'mock'] + if v not in supported: + raise ValueError(f"Provider must be one of {supported}, got: {v}") + return v + + +class AgentConfig(BaseModel): + """Agent-specific configuration model.""" + + name: str = Field(..., description="Agent name") + description: str = Field(..., description="Agent description") + system_prompt: str = Field(..., description="System prompt for the agent") + tools: List[str] = Field(default_factory=list, description="List of enabled tools") + max_iterations: int = Field(10, gt=0, description="Maximum graph iterations") + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=str(_PROJECT_ROOT / ".env"), extra="ignore", case_sensitive=False) + """ + Main settings class with support for environment variables and YAML files. + + Configuration can be loaded from: + 1. Environment variables (highest priority) + 2. .env file + 3. YAML file (via from_yaml method) + 4. Default values (lowest priority) + """ + + # App settings + APP_NAME: str = Field(default="Agent Microservice", description="Application name") + VERSION: str = Field(default="1.0.0", description="Application version") + DEBUG: bool = Field(default=False, description="Debug mode") + + # LLM settings + LLM_PROVIDER: str = Field(default="oci_openai", description="LLM provider") + LLM_MODEL: str = Field(default="gpt-4", description="LLM model name") + LLM_TEMPERATURE: float = Field(default=0.7, ge=0.0, le=2.0, description="LLM temperature") + LLM_MAX_TOKENS: Optional[int] = Field(default=None, gt=0, description="Max tokens") + + # Classification LLM settings + CLASSIFICATION_LLM_MODEL: str = Field(default="bo_gptoss20b_dev", description="Model name for classification node") + CLASSIFICATION_LLM_TEMPERATURE: float = Field(default=0.3, ge=0.0, le=2.0, description="Temperature for classification node") + CLASSIFICATION_LLM_MAX_TOKENS: int = Field(default=1024, gt=0, description="Max tokens for classification node") + CLASSIFICATION_LLM_TOP_P: float = Field(default=0.8, description="Top P for classification node") + CLASSIFICATION_LLM_TOP_K: float = Field(default=250, description="Top K for classification node") + + # Large Classification LLM settings (for complex reasoning/canceling) + CLASSIFICATION_LARGE_LLM_MODEL: str = Field(default="bo_gptoss120b_dev", description="Large model name for critical classification steps") + CLASSIFICATION_LARGE_LLM_TEMPERATURE: float = Field(default=0.3, ge=0.0, le=2.0, description="Temperature for large classification node") + CLASSIFICATION_LARGE_LLM_MAX_TOKENS: int = Field(default=1024, gt=0, description="Max tokens for large classification node") + CLASSIFICATION_LARGE_LLM_TOP_P: float = Field(default=0.8, description="Top P for large classification node") + CLASSIFICATION_LARGE_LLM_TOP_K: float = Field(default=250, description="Top K for large classification node") + + # API Keys + OPENAI_API_KEY: Optional[str] = Field(default=None, description="OpenAI API key") + ANTHROPIC_API_KEY: Optional[str] = Field(default=None, description="Anthropic API key") + + # Server settings + HOST: str = Field(default="0.0.0.0", description="Server host") + PORT: int = Field(default=8000, gt=0, lt=65536, description="Server port") + + # Logging + LOG_LEVEL: str = Field(default="INFO", description="Logging level") + LOG_FORMAT: str = Field(default="text", description="Log format (json or text)") + LOG_NOISY_LEVEL: str = Field(default="WARNING", description="Level applied to chatty third-party loggers (pymongo, urllib3, httpx, ...)") + + # Checkpointing + CHECKPOINT_BACKEND: str = Field(default="memory", description="Checkpoint backend (memory or mongodb)") + MONGODB_CONNECTION_STRING: Optional[str] = Field(default=None, description="MongoDB connection string") + MONGODB_DATABASE: str = Field(default="agent_db", description="MongoDB database name") + MONGODB_COLLECTION: str = Field(default="checkpoints", description="MongoDB collection name") + + # Autonomous Database / MongoDB Settings (memory cache + business data) + AUTONOMOUS_NOSQL_HOST: Optional[str] = Field(default=None, description="MongoDB host (OCI Autonomous)") + AUTONOMOUS_NOSQL_USER: Optional[str] = Field( + default=None, + description="MongoDB username (shared with TAIS via MONGODB_DB_USER alias)", + validation_alias=AliasChoices("AUTONOMOUS_NOSQL_USER", "MONGODB_DB_USER"), + ) + AUTONOMOUS_NOSQL_PASSWORD: Optional[str] = Field( + default=None, + description="MongoDB password (shared with TAIS via MONGODB_DB_PASSWORD alias)", + validation_alias=AliasChoices("AUTONOMOUS_NOSQL_PASSWORD", "MONGODB_DB_PASSWORD", "MONGO_DB_PASSWORD"), + ) + AUTONOMOUS_NOSQL_DB_NAME: Optional[str] = Field(default=None, description="MongoDB database name") + AUTONOMOUS_NOSQL_URL: Optional[str] = Field(default=None, description="MongoDB full connection URL (alternative to host+user+password)") + AUTONOMOUS_NOSQL_COLLECTION: str = Field(default="cases", description="Default collection for business data") + AUTONOMOUS_NOSQL_MEMORY_COLLECTION: str = Field(default="memory", description="Collection for cache/memory data") + USE_AUTONOMUS_NOSQL_LOCAL: bool = Field(default=False, description="Whether to use a local AutonomousDB instance instead of OCI Autonomous Database (for development/testing)") + + # OCI Streaming settings + ENABLE_OCI_STREAMING: bool = Field(default=True, description="Whether to enable OCI Streaming consumer") + OCI_STREAM_ENDPOINT: Optional[str] = Field(default=None, description="OCI Streaming Custom Endpoint (e.g. https://10.152.97.46)") + OCI_REGION: str = Field(default="sa-saopaulo-1", description="OCI Region") + OCI_REQUEST_STREAM_OCID: Optional[str] = Field(default=None, description="OCID of the request stream") + OCI_RESPONSE_STREAM_OCID: Optional[str] = Field(default=None, description="OCID of the response stream") + OCI_CONSUMER_GROUP_NAME: str = Field(default="cg-case-process-bo-agent", description="Group name for OCI streaming consumer") + OCI_MESSAGES_LIMIT: int = Field(default=10, gt=0, description="Max messages to fetch per poll") + OCI_CONFIG_PROFILE: str = Field(default="DEFAULT", description="OCI config profile to use") + OCI_RESOLVE_TO_IP: Optional[str] = Field(default=None, description="IP to resolve the custom endpoint to") + OCI_STREAMING_SEMAPHORE_LIMIT: int = Field(default=8, gt=0, description="Max concurrent message processing (semaphore limit)") + + # Response Emulator settings + EMULATOR_TEMPLATES_TOP_K: int = Field(default=5, gt=0, description="Top-K retrieved from the templates RAG in the response emulator") + EMULATOR_HISTORY_HIGH_SCORE_TOP_K: int = Field(default=5, gt=0, description="Top-K retrieved from the history RAG for the high-score bucket — positive references the model should learn from") + EMULATOR_HISTORY_LOW_SCORE_TOP_K: int = Field(default=5, gt=0, description="Top-K retrieved from the history RAG for the low-score bucket — negative examples the model must avoid imitating") + + # Agent settings (optional) + AGENT_NAME: Optional[str] = Field(default=None, description="Agent name") + AGENT_DESCRIPTION: Optional[str] = Field(default=None, description="Agent description") + AGENT_SYSTEM_PROMPT: Optional[str] = Field(default=None, description="Agent system prompt") + AGENT_MAX_ITERATIONS: int = Field(default=10, gt=0, description="Max agent iterations") + AGENT_TYPE: str = Field(default="backoffice", description="Used as service.agent_type in application logs") + + # IMDB API settings + PMID_API_HOST: str = Field(default="0.0.0.0", description="IMDB access host") + PMID_API_BASIC_TOKEN: str = Field(default="Basic base64_encoded_token", description="IMDB access basic token") + PMID_API_TIMEOUT: Optional[float] = Field(default=10.0, description="IMDB API timeout in seconds") + PMID_API_CLIENT_ID: str = Field(default="client_id", description="IMDB API client id") + + # Siebel API settings + SIEBEL_API_HOST: Optional[str] = Field(default="https://siebel-api-url", description="Siebel API host ") + SIEBEL_API_ROUTE: str = Field(default="/customers/v1/backOfficeSRopening", description="Siebel API route (path)") + SIEBEL_API_TIMEOUT: int = Field(default=10, description="Siebel API timeout in seconds") + SIEBEL_API_USERNAME: Optional[str] = Field(default=None, description="Siebel API username") + SIEBEL_API_PASSWORD: Optional[str] = Field(default=None, description="Siebel API password") + SIEBEL_API_CLIENT_ID: Optional[str] = Field(default=None, description="Siebel API client id") + + # Siebel Prospect API settings (non-TIM / canceled customers) + SIEBEL_PROSPECT_API_HOST: Optional[str] = Field(default=None, description="Siebel Prospect API host (base only)") + SIEBEL_PROSPECT_API_ROUTE: str = Field(default="", description="Siebel Prospect API route (path)") + SIEBEL_PROSPECT_API_AUTHORIZATION: Optional[str] = Field(default=None, description="Siebel Prospect API Authorization header (Basic)") + SIEBEL_PROSPECT_API_CLIENT_ID: Optional[str] = Field(default=None, description="Siebel Prospect API client id") + + # Siebel SR status update (closing) settings + SIEBEL_STATUS_API_HOST: Optional[str] = Field(default=None, description="Siebel Pós-pago: host do endpoint de fechamento da SR") + SIEBEL_STATUS_API_ROUTE: str = Field(default="/interactions/v1/statusServiceRequest", description="Siebel Pós-pago: rota para fechamento da SR") + SIEBEL_PREPAGO_STATUS_API_HOST: Optional[str] = Field(default=None, description="Siebel Pré-pago: host do endpoint de fechamento") + SIEBEL_PREPAGO_STATUS_API_ROUTE: str = Field(default="/customers/v1/serviceRequest", description="Siebel Pré-pago: rota do endpoint de fechamento") + + # ABRT API settings + ABRT_API_BASE_URL: str = Field(default="0.0.0.0", description="ABRT API host") + ABRT_API_AUTHORIZATION: str = Field(default="Basic <base64-credentials>", description="ABRT API Authorization header (Basic)") + ABRT_API_TOKEN: str = Field(default="token", description="ABRT API token header") + ABRT_API_TIMEOUT: Optional[float] = Field(default=10.0, description="ABRT API timeout in seconds") + ABRT_API_CLIENT_ID: str = Field(default="clientId", description="ABRT API client id") + + # Portability API settings + PORTABILITY_API_URL: str = Field(default="0.0.0.0", description="Portability API URL") + PORTABILITY_API_AUTHORIZATION: str = Field(default="username", description="API basic authorization") + PORTABILITY_API_TIMEOUT: Optional[float] = Field(default=10.0, description="Portability API timeout in seconds") + PORTABILITY_API_CLIENT_ID: str = Field(default="clientId", description="Portability API client id") + + # SSL + VERIFY_SSL: bool = Field(default=True, description="Verify SSL") + + # Langfuse settings + USE_LANGFUSE_PROMPTS: bool = Field(default=True, description="Whether to use prompts from Langfuse") + USE_LANGFUSE_TRACING: bool = Field(default=True, description="Whether to send traces/generations to Langfuse") + LANGFUSE_PROMPTS_TTL: int = Field(default=600, description="TTL for Langfuse prompts cache in seconds") + LANGFUSE_SECRET_KEY: str | None = Field(default=None, description="Langfuse secret key", validation_alias=AliasChoices("LANGFUSE_SECRET_KEY", "langfuse_secret_key")) + LANGFUSE_PUBLIC_KEY: str | None = Field(default=None, description="Langfuse public key", validation_alias=AliasChoices("LANGFUSE_PUBLIC_KEY", "langfuse_public_key")) + LANGFUSE_BASE_URL: str | None = Field(default=None, description="Langfuse base URL", validation_alias=AliasChoices("LANGFUSE_BASE_URL", "langfuse_base_url", "LANGFUSE_HOST", "langfuse_host")) + LANGFUSE_ENVIRONMENT: str = Field(default="development", description="Langfuse tracing environment (e.g. development, staging, production)") + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE: Optional[str] = Field( + default=None, + description="Path to the PEM/CRT file with the CA bundle used to verify the Langfuse OTLP endpoint. Required in TIM environments where Langfuse is served behind an internal CA. Leave empty for local dev over plain HTTP.", + ) + + # Anatel Dictionary settings + USE_FULL_ANATEL_DICT: bool = Field(default=False, description="Whether to use the full Anatel motives dictionary instead of filtering by service") + + # Speech Analytics API settings + SPEECH_PREDICTION_BASE_URL: Optional[str] = Field(default=None, description="Base URL of the Speech Prediction API gateway (OAuth2 + prediction endpoint)") + SPEECH_PREDICTION_CLIENT_ID: Optional[str] = Field(default=None, description="Client ID for the Speech Prediction OAuth2 client_credentials flow") + SPEECH_PREDICTION_CLIENT_SECRET: Optional[str] = Field(default=None, description="Client Secret for the Speech Prediction OAuth2 client_credentials flow") + SPEECH_HISTORY_BASE_URL: Optional[str] = Field(default=None, description="Base URL of the Speech History API (audio-toxico)") + SPEECH_HISTORY_AUDIENCE: Optional[str] = Field(default=None, description="Google ID Token target audience for the Speech History API") + SPEECH_HISTORY_HOST: Optional[str] = Field(default=None, description="Host header value for the Speech History API when calling via internal IP") + SPEECH_TIMEOUT: float = Field(default=10.0, description="Timeout in seconds shared by both Speech API calls (prediction and history)") + SPEECH_SIMILARITY_THRESHOLD: int = Field(default=70, ge=0, le=100, description="Minimum LLM-judged similarity percent (0-100) for a historical complaint to appear in historico_relacionado") + + # TAIS Knowledge Base settings + MONGODB_DB_USER: Optional[str] = Field( + default=None, + description="Oracle ADB user for TAIS knowledge base (shares Mongo credentials)", + validation_alias=AliasChoices("MONGODB_DB_USER", "TAIS_DB_USER"), + ) + MONGODB_DB_PASSWORD: Optional[str] = Field( + default=None, + description="Oracle ADB password for TAIS knowledge base (shares Mongo credentials)", + validation_alias=AliasChoices("MONGODB_DB_PASSWORD", "TAIS_DB_PASSWORD", "MONGO_DB_PASSWORD"), + ) + TAIS_DB_DSN: Optional[str] = Field(default=None, description="Oracle ADB DSN for TAIS knowledge base") + TAIS_DB_TIMEOUT: float = Field(default=30.0, description="Timeout in seconds for TAIS DB connection and OCI GenAI embedding calls") + TAIS_GENAI_ENDPOINT: Optional[str] = Field(default=None, description="OCI GenAI endpoint used for embeddings") + TAIS_GENAI_COMPARTMENT_ID: Optional[str] = Field(default=None, description="OCI compartment OCID used for embeddings") + TAIS_GENAI_EMBED_MODEL_ID: str = Field(default="cohere.embed-multilingual-v3.0", description="OCI GenAI embedding model id") + TAIS_TABLE_CHUNKS: str = Field(default="CHUNKS_CHAR_COHERE_3", description="Oracle table containing TAIS chunks + embeddings") + TAIS_TABLE_FILES: str = Field(default="files_oci", description="Oracle table containing TAIS raw documents") + TAIS_TOP_K: int = Field(default=3, gt=0, description="Number of unique documents returned by TAIS KB search") + TAIS_KB_LLM_MODEL: str = Field(default="bo_gptoss20b_dev", description="Modelo LLM para pós-processamento da KB (bo_gptoss20b_dev ou bo_gptoss120b_dev)") + TAIS_KB_LLM_TEMPERATURE: float = Field(default=0.3, ge=0.0, le=2.0) + TAIS_KB_LLM_MAX_TOKENS: int = Field(default=4096, gt=0, description="Tokens de saída — manter alto para respostas completas") + TAIS_KB_LLM_TOP_P: float = Field(default=0.9) + TAIS_KB_LLM_TOP_K: float = Field(default=250) + TAIS_KB_USE_SPEECH_SUMMARY: bool = Field(default=False, description="Se True, usa speech.reclamacao_resumo como query; se False, usa complaint.description") + TAIS_KB_PREPROCESS: bool = Field(default=False, description="Pré-processa a query com LLM antes do embedding") + TAIS_KB_POSTPROCESS: bool = Field(default=True, description="Pós-processa resultados com LLM para síntese") + + # Emulator RAG (Oracle ADB + OCI Cohere embeddings). DB credentials reuse the TAIS settings. + EMULATOR_RAG_OCI_GENAI_ENDPOINT: Optional[str] = Field(default=None, description="OCI GenAI endpoint for emulator RAG embeddings") + EMULATOR_RAG_COMPARTMENT_ID: Optional[str] = Field(default=None, description="OCI compartment OCID for emulator RAG embeddings") + EMULATOR_RAG_EMBED_MODEL_ID: str = Field(default="cohere.embed-multilingual-v3.0", description="OCI GenAI embedding model for emulator RAG") + EMULATOR_RAG_TEMPLATES_CHUNKS: str = Field(default="TEMPLATES_COHERE_3", description="Oracle table with templates chunks + embeddings") + EMULATOR_RAG_ANATEL_NOTAS_RESPOSTA_CHUNKS: str = Field(default="ANATEL_NOTAS_CHUNKS_RESPOSTA_COHERE_3", description="Oracle table with approved Anatel response chunks + embeddings") + EMULATOR_RAG_TOP_K: int = Field(default=5, gt=0, description="Default top-k used by the QA /emulator-rag/search route") + EMULATOR_RAG_HISTORY_HIGH_SCORE_THRESHOLD: int = Field(default=4, ge=0, le=5, description="High-score filter for the history RAG (anatel_resposta): chunks with `nota >= threshold` are retrieved as positive references") + EMULATOR_RAG_HISTORY_LOW_SCORE_THRESHOLD: int = Field(default=2, ge=0, le=5, description="Low-score filter for the history RAG (anatel_resposta): chunks with `nota <= threshold` are retrieved as negative examples the model must avoid imitating") + EMULATOR_RAG_ALLOW_MOCK_FALLBACK: bool = Field(default=False, description="LOCAL DEV ONLY: when true, RAG wrappers fall back to JSON mocks if the real backend is unset or fails. Must remain false in any shared environment (dev kube, fqa, prod) to avoid silent mock responses.") + + model_config = { + "env_file": ".env", + "case_sensitive": False, + "extra": "ignore" + } + + @field_validator('LLM_PROVIDER') + @classmethod + def validate_llm_provider(cls, v: str) -> str: + """Validate that LLM provider is supported.""" + supported = ['openai', 'anthropic', 'oci_openai', 'oci', 'mock'] + if v not in supported: + raise ValueError(f"LLM_PROVIDER must be one of {supported}, got: {v}") + return v + + @field_validator('LOG_LEVEL') + @classmethod + def validate_log_level(cls, v: str) -> str: + """Validate log level.""" + valid_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] + v_upper = v.upper() + if v_upper not in valid_levels: + raise ValueError(f"LOG_LEVEL must be one of {valid_levels}, got: {v}") + return v_upper + + @field_validator('LOG_FORMAT') + @classmethod + def validate_log_format(cls, v: str) -> str: + """Validate log format.""" + valid_formats = ['json', 'text'] + v_lower = v.lower() + if v_lower not in valid_formats: + raise ValueError(f"LOG_FORMAT must be one of {valid_formats}, got: {v}") + return v_lower + + @classmethod + def from_yaml(cls, path: str) -> "Settings": + """ + Load settings from a YAML file. + + Args: + path: Path to the YAML configuration file + + Returns: + Settings instance with values loaded from YAML + + Raises: + FileNotFoundError: If the YAML file doesn't exist + yaml.YAMLError: If the YAML file is malformed + ValueError: If required fields are missing or invalid + """ + with open(path, 'r') as f: + config_dict = yaml.safe_load(f) + + if config_dict is None: + config_dict = {} + + return cls(**config_dict) + + def to_llm_config(self) -> LLMConfig: + """ + Convert settings to LLMConfig model. + + Returns: + LLMConfig instance with LLM-specific settings + """ + return LLMConfig( + provider=self.LLM_PROVIDER, + model=self.LLM_MODEL, + temperature=self.LLM_TEMPERATURE, + max_tokens=self.LLM_MAX_TOKENS + ) + + def to_agent_config(self) -> Optional[AgentConfig]: + """ + Convert settings to AgentConfig model if agent settings are present. + + Returns: + AgentConfig instance if all required fields are present, None otherwise + """ + if not all([self.AGENT_NAME, self.AGENT_DESCRIPTION, self.AGENT_SYSTEM_PROMPT]): + return None + + return AgentConfig( + name=self.AGENT_NAME, + description=self.AGENT_DESCRIPTION, + system_prompt=self.AGENT_SYSTEM_PROMPT, + tools=[], # Tools would be configured separately + max_iterations=self.AGENT_MAX_ITERATIONS + ) + + def setup_langfuse(self): + """ + Export Langfuse settings to environment variables. + Many libraries (like the Langfuse client and agent-framework) expect these + to be present in os.environ for authentication. + """ + if self.LANGFUSE_PUBLIC_KEY: + os.environ["LANGFUSE_PUBLIC_KEY"] = self.LANGFUSE_PUBLIC_KEY + + if self.LANGFUSE_SECRET_KEY: + os.environ["LANGFUSE_SECRET_KEY"] = self.LANGFUSE_SECRET_KEY + + if self.LANGFUSE_BASE_URL: + # Ensure protocol is present + url = self.LANGFUSE_BASE_URL + if url and not url.startswith(("http://", "https://")): + url = f"https://{url}" # Default to https if missing + + os.environ["LANGFUSE_BASE_URL"] = url + os.environ["LANGFUSE_HOST"] = url + + os.environ["LANGFUSE_TRACING_ENVIRONMENT"] = self.LANGFUSE_ENVIRONMENT + + +# Global settings instance +settings = Settings() +settings.setup_langfuse() diff --git a/src/core/exceptions.py b/src/core/exceptions.py new file mode 100644 index 0000000..162c585 --- /dev/null +++ b/src/core/exceptions.py @@ -0,0 +1,34 @@ +"""Backoffice-local exception compatibility layer. + +The current agent_framework package used by this project does not expose +framework exception module. The backoffice domain keeps its own +exception classes and the FastAPI middleware handles them locally, while the +runtime itself still uses the framework for workflow, guardrails, judges, +observer and LLM providers. +""" + +from __future__ import annotations + + +class AgentException(Exception): + """Base application exception with an HTTP status code.""" + + def __init__(self, message: str, status_code: int = 500): + super().__init__(message) + self.message = message + self.status_code = status_code + + +class ConfigurationError(AgentException): + def __init__(self, message: str): + super().__init__(message, status_code=500) + + +class LLMError(AgentException): + def __init__(self, message: str): + super().__init__(message, status_code=502) + + +class ToolExecutionError(AgentException): + def __init__(self, message: str): + super().__init__(message, status_code=502) diff --git a/src/core/logging.py b/src/core/logging.py new file mode 100644 index 0000000..d077b7a --- /dev/null +++ b/src/core/logging.py @@ -0,0 +1,523 @@ +""" +Application logging configuration. + +Emits JSON logs in the shape required by the internal OCI Logging policy +(doc 1330576): top-level ``service``/``correlation``/``operation`` blocks, +``source_channel``/``conversation_start_time_ts``/``requesting_agent`` for +conversation context, plus split streams (``< ERROR`` -> stdout, +``>= ERROR`` -> stderr) so the OCI Unified Monitoring Agent can ingest both. + +The transport itself is just stdout/stderr; the OCI Logging Agent provisioned +by infra is responsible for shipping records out of the container. + +Uses a small local StructuredLogger compatibility helper for formatter setup. +""" + +import json +import logging +import sys +import time +from contextvars import ContextVar +from datetime import datetime, timezone +from typing import Any, Dict, Optional + + +class StructuredLogger: # minimal compatibility for text formatter helper + def __init__(self, name: str = "app", use_json: bool = False): + import logging as _logging + self.logger = _logging.getLogger(name) + + +from src.core.config import settings + + +_request_id_var: ContextVar[Optional[str]] = ContextVar("request_id", default=None) +_session_id_var: ContextVar[Optional[str]] = ContextVar("session_id", default=None) +_user_id_var: ContextVar[Optional[str]] = ContextVar("user_id", default=None) +_trace_id_var: ContextVar[Optional[str]] = ContextVar("trace_id", default=None) +_span_id_var: ContextVar[Optional[str]] = ContextVar("span_id", default=None) +_source_channel_var: ContextVar[Optional[str]] = ContextVar("source_channel", default=None) +_conversation_start_var: ContextVar[Optional[str]] = ContextVar("conversation_start_time_ts", default=None) +_requesting_agent_var: ContextVar[Optional[str]] = ContextVar("requesting_agent", default=None) + +_configured: bool = False + +_STD_LOGRECORD_ATTRS = frozenset({ + "name", "msg", "args", "levelname", "levelno", "pathname", "filename", + "module", "exc_info", "exc_text", "stack_info", "lineno", "funcName", + "created", "msecs", "relativeCreated", "thread", "threadName", + "processName", "process", "message", "asctime", "taskName", + "request_id", "session_id", "user_id", "trace_id", "span_id", + "source_channel", "conversation_start_time_ts", "requesting_agent", +}) + + +def _current_otel_ids() -> tuple[Optional[str], Optional[str]]: + """Return (trace_id, span_id) from the active OTEL span, or (None, None). + + Langfuse's start_as_current_observation creates real OTEL spans; reading + them here means logs emitted from inside a node/tool automatically pick + up the same trace_id/span_id that appear in Langfuse — no plumbing + through contextvars. + """ + try: + from opentelemetry import trace as otel_trace + span = otel_trace.get_current_span() + if span is None: + return None, None + span_context = span.get_span_context() + if not span_context or not span_context.is_valid: + return None, None + return format(span_context.trace_id, "032x"), format(span_context.span_id, "016x") + except Exception: + return None, None + + +class _InvalidGrantFilter(logging.Filter): + """Drops repeated PubSub/GCP `invalid_grant` errors that flood local dev. + + These come from `agent_framework.observer.api` publish callbacks when GCP + credentials are absent or expired. The transport error is intentional in + that environment — suppressing keeps logs readable without hiding genuine + PubSub errors (other `Falha callback PubSub` messages still pass through). + """ + + def filter(self, record: logging.LogRecord) -> bool: + try: + message = record.getMessage() + except Exception: + return True + return "invalid_grant" not in message + + +class ContextFilter(logging.Filter): + """Injects correlation/conversation context from contextvars onto each record.""" + + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = _request_id_var.get() + record.session_id = _session_id_var.get() + record.user_id = _user_id_var.get() + otel_trace_id, otel_span_id = _current_otel_ids() + record.trace_id = otel_trace_id or _trace_id_var.get() + record.span_id = otel_span_id or _span_id_var.get() + record.source_channel = _source_channel_var.get() + record.conversation_start_time_ts = _conversation_start_var.get() + record.requesting_agent = _requesting_agent_var.get() + return True + + +def _iso_utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +class _StructuredFormatter(logging.Formatter): + """Renders records in the layout defined by the OCI Logging policy document.""" + + def __init__(self, use_json: bool): + super().__init__() + self.use_json = use_json + # Helper kept only for its text-mode formatter, so framework-internal + # logs and app logs match when JSON is off. + self._helper = StructuredLogger(name="__formatter__", use_json=False) + self._helper.logger.handlers.clear() + self._helper.logger.propagate = False + + def format(self, record: logging.LogRecord) -> str: + message = record.getMessage() + + request_id = getattr(record, "request_id", None) + session_id = getattr(record, "session_id", None) + user_id = getattr(record, "user_id", None) + trace_id = getattr(record, "trace_id", None) + span_id = getattr(record, "span_id", None) + source_channel = getattr(record, "source_channel", None) + conversation_start = getattr(record, "conversation_start_time_ts", None) + requesting_agent = getattr(record, "requesting_agent", None) + + extras = { + k: v for k, v in record.__dict__.items() + if k not in _STD_LOGRECORD_ATTRS and not k.startswith("_") + } + operation = extras.pop("operation", None) + component_override = extras.pop("component", None) + payload = extras.pop("payload", None) or (extras or None) + + component = component_override or record.name.rsplit(".", 1)[-1] + + if self.use_json: + obj: Dict[str, Any] = { + "timestamp": _iso_utc_now(), + "level": record.levelname, + "logger": record.name, + "message": message, + "source_channel": source_channel, + "conversation_start_time_ts": conversation_start, + "requesting_agent": requesting_agent, + "service": { + "name": settings.APP_NAME, + "version": settings.VERSION, + "component": component, + "agent_type": settings.AGENT_TYPE, + }, + "correlation": { + "trace_id": trace_id, + "request_id": request_id, + "span_id": span_id, + "session_id": session_id, + }, + "user_id": user_id, + } + if operation is not None: + obj["operation"] = operation + if payload is not None: + obj["payload"] = payload + if record.exc_info: + obj["exception"] = self.formatException(record.exc_info) + return json.dumps(obj, ensure_ascii=False, default=str) + + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + text = f"{timestamp} - {record.name} - {record.levelname} - {message}" + text += ( + f" | trace_id={trace_id} request_id={request_id}" + f" session_id={session_id} user_id={user_id}" + ) + if operation is not None: + status = operation.get("status") + name = operation.get("name") + text += f" | operation={name}/{status}" + execution_time = operation.get("execution_time") + if execution_time is not None: + text += f" execution_time={execution_time}" + if record.exc_info: + text += "\n" + self.formatException(record.exc_info) + return text + + +def _resolve_level(value: Any) -> int: + if isinstance(value, int): + return value + return getattr(logging, str(value).upper(), logging.INFO) + + +def _build_handler(stream, level: int, use_json: bool, error_only: bool) -> logging.Handler: + handler = logging.StreamHandler(stream) + handler.setLevel(level) + handler.setFormatter(_StructuredFormatter(use_json=use_json)) + handler.addFilter(ContextFilter()) + if error_only: + handler.addFilter(lambda record: record.levelno >= logging.ERROR) + else: + handler.addFilter(lambda record: record.levelno < logging.ERROR) + return handler + + +def setup_logging( + log_level: Optional[str] = None, + log_format: Optional[str] = None, +) -> logging.Logger: + """Configure the root logger once; subsequent calls only adjust level/format.""" + global _configured + + level_str = (log_level or settings.LOG_LEVEL) + fmt_str = (log_format or settings.LOG_FORMAT).lower() + level = _resolve_level(level_str) + use_json = fmt_str == "json" + + root = logging.getLogger() + + if _configured: + root.setLevel(level) + for h in root.handlers: + # Stderr handler is pinned to ERROR; only stdout follows the configured level. + if getattr(h, "_logs_below_error", False): + h.setLevel(level) + h.setFormatter(_StructuredFormatter(use_json=use_json)) + return root + + for h in list(root.handlers): + root.removeHandler(h) + + stdout_handler = _build_handler(sys.stdout, level, use_json, error_only=False) + stdout_handler._logs_below_error = True # type: ignore[attr-defined] + stderr_handler = _build_handler(sys.stderr, logging.ERROR, use_json, error_only=True) + + root.addHandler(stdout_handler) + root.addHandler(stderr_handler) + root.setLevel(level) + + # Force most third-party loggers to propagate through root so they share the + # same JSON shape. Strip handlers they install on import (oci, langfuse). + for name in ( + "oci", "oci.circuit_breaker", + "langfuse", "httpx", "httpcore", + "pymongo", "urllib3", + ): + lg = logging.getLogger(name) + for h in list(lg.handlers): + lg.removeHandler(h) + lg.propagate = True + + # Uvicorn keeps its own (colored, human-friendly) handlers so the terminal + # stays readable in dev. We set propagate=False to avoid duplicating each + # access/error line through our JSON pipeline. In prod the OCI Logging Agent + # still ingests these lines from stdout/stderr; they just won't be in our + # JSON shape — that's a deliberate trade-off for terminal readability. + for name in ("uvicorn", "uvicorn.access"): + logging.getLogger(name).propagate = False + + noisy_level = _resolve_level(settings.LOG_NOISY_LEVEL) + for noisy in ( + "httpx", "httpcore", "urllib3", "urllib3.connectionpool", + "pymongo", "pymongo.serverSelection", "pymongo.topology", + "pymongo.command", "pymongo.connection", "pymongo.heartbeat", + "uvicorn.access", "agent_framework.observer.api", + ): + logging.getLogger(noisy).setLevel(noisy_level) + + # grpc._plugin_wrapping emits repeated ERROR logs when Google credentials are + # absent or expired (e.g. local dev without GOOGLE_APPLICATION_CREDENTIALS). + # The actual failure surfaces as SpeechClientError to the caller — suppress here. + logging.getLogger("grpc._plugin_wrapping").setLevel(logging.CRITICAL) + + # agent_framework.observer.api retries PubSub publishes on every event; when + # GCP credentials are absent/expired the future callback raises "invalid_grant" + # repeatedly. Drop only those — other PubSub errors stay visible. + logging.getLogger("agent_framework.observer.api").addFilter(_InvalidGrantFilter()) + + _configured = True + return root + + +def setup_logging_from_settings() -> logging.Logger: + """Backwards-compatible alias.""" + return setup_logging() + + +def get_logger(name: str) -> logging.Logger: + """Return a stdlib logger that propagates to the configured root.""" + if not _configured: + setup_logging() + return logging.getLogger(name) + + +# --------------------------------------------------------------------------- +# Context variable accessors +# --------------------------------------------------------------------------- + +def get_request_id() -> Optional[str]: + return _request_id_var.get() + + +def set_request_id(value: Optional[str]) -> None: + _request_id_var.set(value) + + +def set_session_id(value: Optional[str]) -> None: + _session_id_var.set(value) + + +def set_user_id(value: Optional[str]) -> None: + _user_id_var.set(value) + + +def set_trace_id(value: Optional[str]) -> None: + _trace_id_var.set(value) + + +def set_span_id(value: Optional[str]) -> None: + _span_id_var.set(value) + + +def set_source_channel(value: Optional[str]) -> None: + _source_channel_var.set(value) + + +def set_conversation_start_ts(value: Optional[str]) -> None: + _conversation_start_var.set(value) + + +def set_requesting_agent(value: Optional[str]) -> None: + _requesting_agent_var.set(value) + + +def clear_context() -> None: + _request_id_var.set(None) + _session_id_var.set(None) + _user_id_var.set(None) + _trace_id_var.set(None) + _span_id_var.set(None) + _source_channel_var.set(None) + _conversation_start_var.set(None) + _requesting_agent_var.set(None) + + +# --------------------------------------------------------------------------- +# Operation helper (start/success/failed pattern from the OCI Logging policy) +# --------------------------------------------------------------------------- + +class log_operation: + """ + Context manager that emits ``started``/``success``/``failed`` logs around + an operation, populating the ``operation`` block defined by the OCI Logging + policy (name, status, execution_time, plus any custom fields). + + Works as both ``with`` and ``async with`` so it can be used uniformly in + sync and async code paths. + + Example:: + + async with log_operation("process", component="llm_node") as op: + op.add_field("message_count", len(state["messages"])) + response = await llm.ainvoke(...) + op.add_field("response_length", len(response.content)) + + On exception, ``failed`` is logged with ``exc_info`` and the exception is + re-raised. + """ + + def __init__( + self, + name: str, + *, + component: Optional[str] = None, + logger: Optional[logging.Logger] = None, + start_message: Optional[str] = None, + success_message: Optional[str] = None, + **fields: Any, + ): + self._name = name + self._component = component + self._logger = logger or get_logger("operation") + self._fields: Dict[str, Any] = dict(fields) + self._start_message = start_message or f"{name} started" + self._success_message = success_message or f"{name} completed" + self._start_time: Optional[float] = None + + def add_field(self, key: str, value: Any) -> None: + """Attach a field to be emitted on subsequent operation logs.""" + self._fields[key] = value + + def event(self, message: str, *, level: int = logging.INFO, **fields: Any) -> None: + """Log a mid-operation event without closing the operation.""" + op_block = {"name": self._name, "status": "in_progress", **self._fields, **fields} + self._emit(level, message, op_block) + + def _emit(self, level: int, message: str, op_block: Dict[str, Any], **kwargs: Any) -> None: + extra: Dict[str, Any] = {"operation": op_block} + if self._component: + extra["component"] = self._component + self._logger.log(level, message, extra=extra, **kwargs) + + def _elapsed(self) -> float: + if self._start_time is None: + return 0.0 + return round(time.perf_counter() - self._start_time, 3) + + def _enter(self) -> "log_operation": + self._start_time = time.perf_counter() + self._emit( + logging.INFO, + self._start_message, + {"name": self._name, "status": "started", **self._fields}, + ) + return self + + def _exit(self, exc_type, exc, tb) -> bool: + elapsed = self._elapsed() + if exc is not None: + op_block = { + "name": self._name, + "status": "failed", + "execution_time": elapsed, + "error_type": type(exc).__name__, + **self._fields, + } + self._emit( + logging.ERROR, + f"{self._name} failed: {exc}", + op_block, + exc_info=(exc_type, exc, tb), + ) + else: + op_block = { + "name": self._name, + "status": "success", + "execution_time": elapsed, + **self._fields, + } + self._emit(logging.INFO, self._success_message, op_block) + return False # never suppress exceptions + + def __enter__(self) -> "log_operation": + return self._enter() + + def __exit__(self, exc_type, exc, tb) -> bool: + return self._exit(exc_type, exc, tb) + + async def __aenter__(self) -> "log_operation": + return self._enter() + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return self._exit(exc_type, exc, tb) + + +class MetricsLogger: + """ + Lightweight timer/metrics helper. Kept for backwards compatibility — new + code should prefer ``log_operation`` which already captures duration and + follows the OCI Logging policy structure. + """ + + def __init__(self, operation: str, logger: Optional[logging.Logger] = None): + self.operation = operation + self.logger = logger or get_logger("metrics") + self.start_time: Optional[float] = None + self.end_time: Optional[float] = None + + def start(self) -> "MetricsLogger": + self.start_time = time.perf_counter() + self.end_time = None + return self + + def stop(self) -> float: + if self.start_time is None: + return 0.0 + self.end_time = time.perf_counter() + return self.end_time - self.start_time + + @property + def elapsed_time(self) -> float: + if self.start_time is None: + return 0.0 + end = self.end_time if self.end_time is not None else time.perf_counter() + return end - self.start_time + + def log(self, level: int = logging.INFO, **metrics: Any) -> None: + if self.start_time is not None and self.end_time is None: + self.stop() + payload: Dict[str, Any] = { + "operation": self.operation, + "elapsed_seconds": round(self.elapsed_time, 6), + } + payload.update(metrics) + self.logger.log(level, f"Metrics: {self.operation}", extra={"payload": payload}) + + +__all__ = [ + "setup_logging", + "setup_logging_from_settings", + "get_logger", + "get_request_id", + "set_request_id", + "set_session_id", + "set_user_id", + "set_trace_id", + "set_span_id", + "set_source_channel", + "set_conversation_start_ts", + "set_requesting_agent", + "clear_context", + "ContextFilter", + "MetricsLogger", + "log_operation", +] diff --git a/src/core/prompt_manager.py b/src/core/prompt_manager.py new file mode 100644 index 0000000..3c7280c --- /dev/null +++ b/src/core/prompt_manager.py @@ -0,0 +1,82 @@ +import time +from src.core.config import settings +from typing import Dict, Tuple +from src.providers.langfuse_provider import get_prompt_with_config_from_langfuse +from src.core.logging import get_logger + +logger = get_logger(__name__) + +# Simple TTL Cache: {prompt_name: ((content, config), timestamp)}. +# Cache compartilhado entre `get_prompt` (body-only) e +# `get_prompt_with_config` — um único fetch popula ambas as APIs. +_prompt_cache: Dict[str, Tuple[Tuple[str, dict], float]] = {} + + +def get_prompt_with_config( + prompt_name: str, + local_fallback: str, + local_fallback_config: dict | None = None, +) -> Tuple[str, dict]: + """ + Retrieves a prompt (body + config) from Langfuse with local fallback + and TTL caching. + + O `config` é o objeto JSON anexo ao prompt no Langfuse, usado para + parâmetros tuneáveis sem deploy (ex.: tamanho de janelas, thresholds). + + Args: + prompt_name: Nome do prompt no Langfuse. + local_fallback: Corpo do prompt usado quando Langfuse está + indisponível ou desligado. + local_fallback_config: Config (dict) usado no mesmo cenário de + fallback. `None` é normalizado para `{}`. + + Returns: + Tupla `(content, config)`. + """ + fallback_config = local_fallback_config or {} + + if not settings.USE_LANGFUSE_PROMPTS: + logger.warning("Langfuse prompts disabled. Using local prompts.") + return local_fallback, fallback_config + + current_time = time.time() + + cached = _prompt_cache.get(prompt_name) + if cached is not None: + (content, config), timestamp = cached + if current_time - timestamp < settings.LANGFUSE_PROMPTS_TTL: + logger.debug(f"Prompt '{prompt_name}' retrieved from cache.") + return content, config + + try: + logger.info(f"Fetching prompt '{prompt_name}' from Langfuse...") + content, config = get_prompt_with_config_from_langfuse(prompt_name) + + if content: + _prompt_cache[prompt_name] = ((content, config), current_time) + logger.info(f"Prompt '{prompt_name}' successfully fetched and cached.") + return content, config + + logger.warning(f"Prompt '{prompt_name}' not found in Langfuse. Falling back to local prompt.") + return local_fallback, fallback_config + + except Exception as e: + logger.error(f"Error fetching prompt '{prompt_name}' from Langfuse: {e}. Falling back to local.") + return local_fallback, fallback_config + + +def get_prompt(prompt_name: str, local_fallback: str) -> str: + """ + Retrieves only the prompt body. Thin wrapper sobre + `get_prompt_with_config` para callers que não precisam do `config`. + """ + content, _config = get_prompt_with_config(prompt_name, local_fallback) + return content + + +def clear_cache(): + """Clears the prompt cache.""" + global _prompt_cache + _prompt_cache = {} + logger.info("Prompt cache cleared.") diff --git a/src/core/state_logger.py b/src/core/state_logger.py new file mode 100644 index 0000000..254050f --- /dev/null +++ b/src/core/state_logger.py @@ -0,0 +1,15 @@ +import logging + + +logger = logging.getLogger(__name__) + +def log_state_transition(previous, current, metadata=None): + + logger.info( + "state_transition", + extra={ + "previous_state": previous, + "current_state": current, + "metadata": metadata or {} + } + ) \ No newline at end of file diff --git a/src/core/streaming/__pycache__/client.cpython-313.pyc b/src/core/streaming/__pycache__/client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3bb0ad7d529527f7a7c59ae429b97ed742236a4c GIT binary patch literal 1137 zcmZuw%}*0i5TE_3?NU-A(9#+R5Irm;T_kWojEO-o6>UiuPLRB;%eGn4?$)=9(VHjZ zK@S`Ve}n&uw?>niXFYl1hN(u6&fD$MDo(O9^XB`_?`>~%R0X7OALfoe2`Kp?Cb_Tl z?x1oC1{lINP%g!7;gLwiS(t)(Fr)$)@~Rj`Q|UmOht+;6mFt40obyvz6Ijx>gTQg? zzSZ}K0{e#4E1)=q1E7KdR5S!C8DasbY)Dk8%bLQjS1iW}sBJc}$VS7l-QZloBr7h# z>u`gFU+*`vQ`koyQMEr}eCD#sqyM4(L~N)gV{IUZyy5Bw9<CX}nz#pz%Xx;nr~i)R zr)uV^Z4dr8=TO(Nt2u@kc1^{eI%rZ^6WM6ZY1pLUS!UpPE=#wl7g}7iX`?$~d2Y?A zvoYVM&mGGqwtLv}95>KZCV7@a`2|_3M!hECd6ch}WhxsbvQa5-l^&9<(&J4;WnkE; zRzh3Kk6~iz2(bt*HqL{M-RkE3CuFnyV5d?l@8TdKmiR(s-)_`=zP?#j)SGUgADV%w z*PIu`YSna~TDs*?TfZ!?N2$fuOO_<Wah-q=cH`<hDaOugSld59_6oY${MpgTQ7605 z&MtHd^PR$SyRh8N&vo)E?flAx0=dNtkaFr9`R&LL6>^2M=1H@YU212SK9A?l)=$>E z)3-X)YwhVZ9(=nW{BER&^+6NqIErwKWi7$y<BP}nR6?5G;W1)ESelS$$7ZAN$q?K$ z^@E1v+O9_k&0+;MNr)dHh88h{fI3f)1KTI$J<us$f08r)jzh*3pI`Dd{FGCQ8pUv{ zrcnL{Sq8UQ<Xc3>@eg4QbJ+x<j;;EC7>RaEXE1BfiY4zx!{4KG=-^oSv&b$aK@h%x a`W0^eN{k5Vci?OWrWQWt7TYipR`>$}KKpP0 literal 0 HcmV?d00001 diff --git a/src/core/streaming/client.py b/src/core/streaming/client.py new file mode 100644 index 0000000..d66e052 --- /dev/null +++ b/src/core/streaming/client.py @@ -0,0 +1,20 @@ +import oci +from src.core.config import settings + + +class OciStreamingClient: + + def __init__(self): + self.config = oci.config.from_file( + file_location=settings.OCI_CONFIG_FILE, + profile_name=settings.OCI_CONFIG_PROFILE + ) + + self.client = oci.streaming.StreamClient( + config=self.config, + service_endpoint=settings.OCI_STREAM_ENDPOINT + ) + + +# Singleton +oci_streaming_client = OciStreamingClient() \ No newline at end of file diff --git a/src/infrastructure/.DS_Store b/src/infrastructure/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a85102ac1d9043e06bb0e4414f4f1f7f1f0028fa GIT binary patch literal 6148 zcmeHKJ8r^25Pb_2pfr>z_X_kB4suRFl_pAn2#_#F`Sfnn<XUNSgtWOu-t3HsEt`~4 zgl44Kx1P`L`q}b$0EF4gY66S_45@;>5r+;Dzi3N(W)WwQH74_ASsYq%Zuq|p$g}IC z#1h-xy8irLZ+|>RndggLxu8YjZ>C@GS9R~|7w=(HU-gq|7~u)ec*FzlQDBQb*3G@& z_-1xRoj6CwdPUiJrjE$w`99=)F3x~6;0&BQ1FYF1!wW?podIXS88|W^--k#Q%nU2V z^y%P`S^(mJ<|Ld;FCjV6Ff*(a>7fKoC2DH079(gn?a|^g!%9)p5v=(LwsWu!C8XWC zeoWyAnWB%*fHTl!ps$x>ssGof`~PN=UpWKLz*#X62KhXn@s_N%wr);pZKU2&MI^3L lJf?7xN-<-l6kk%4&>m$%%nU0<S}6V_kZAD18TeBMz5#~QM%DlT literal 0 HcmV?d00001 diff --git a/src/infrastructure/oci/autonomous/__init__.py b/src/infrastructure/oci/autonomous/__init__.py new file mode 100644 index 0000000..70a3e98 --- /dev/null +++ b/src/infrastructure/oci/autonomous/__init__.py @@ -0,0 +1,3 @@ +from .connection import db_manager, AutonomousManager + +__all__ = ["db_manager", "AutonomousManager"] diff --git a/src/infrastructure/oci/autonomous/__pycache__/__init__.cpython-313.pyc b/src/infrastructure/oci/autonomous/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f6c7985fd45077ae06d1eac7b7db827bd60b265a GIT binary patch literal 328 zcmYLF%}N7744#=?X<6w(Jc!_-cw0~h1aBhrXr&_T1BB`9Y?*d<MzS-CM<2q6@NIhD zi-IR_LSMiv{xpZ=OY(go$!0Xl7}@#ccxfnp^vR!zU$8kM@WKjK@EbPe$fp9um=&T( zq`+j6<~yI9D7omeat@7hYGJV3o5bLqZ+vWLKP60vg(<n)Jnb^=oijRE@3xI@0HEp` z;FWEN$GRvR7i1}C#r60O{gMQ&r1>sDg{m=+9#`PCm2;(6zN)M?K!YC)23z_qN4BV? zb);znMjav=*~`@bk`ll=8vxGkpSs~?<I7l^EA0Q%U)S!F`${<HZ|vfI_h5Z^vd&JQ I2RNkqH`NDS>Hq)$ literal 0 HcmV?d00001 diff --git a/src/infrastructure/oci/autonomous/__pycache__/cases_manager.cpython-313.pyc b/src/infrastructure/oci/autonomous/__pycache__/cases_manager.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ca8407d0d0933e0f0fab53940bae1efe52480e3f GIT binary patch literal 2934 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 B^fUkf literal 0 HcmV?d00001 diff --git a/src/infrastructure/oci/autonomous/__pycache__/connection.cpython-313.pyc b/src/infrastructure/oci/autonomous/__pycache__/connection.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..159a00c4bf3f5324ea29f82599a769a35613cec6 GIT binary patch literal 11174 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 literal 0 HcmV?d00001 diff --git a/src/infrastructure/oci/autonomous/__pycache__/memory_manager.cpython-313.pyc b/src/infrastructure/oci/autonomous/__pycache__/memory_manager.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c9f85a36fae345949e6c6bcbb0e5d5185b072f30 GIT binary patch literal 5708 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 BGeiIY literal 0 HcmV?d00001 diff --git a/src/infrastructure/oci/autonomous/cases_manager.py b/src/infrastructure/oci/autonomous/cases_manager.py new file mode 100644 index 0000000..e8959b5 --- /dev/null +++ b/src/infrastructure/oci/autonomous/cases_manager.py @@ -0,0 +1,67 @@ +from src.infrastructure.oci.autonomous.connection import db_manager +from src.core.config import settings +from src.core.logging import get_logger + +logger = get_logger(__name__) + +CASES_COLLECTION = settings.AUTONOMOUS_NOSQL_COLLECTION + +BYPASS_STATUSES = { + "opened_cancelation_sr", + "opened_reclassification_sr", + "opened_forwarding_sr", +} + + +async def has_bypass_eligible_history(protocol: str) -> str | None: + """ + Returns the transactionId of the first prior case whose processing.status + qualifies for bypass, or None if no such case exists. + + Bypass-eligible statuses: opened_cancelation_sr, opened_reclassification_sr, + opened_forwarding_sr. + """ + if db_manager.db is None: + logger.warning( + "bypass_history_lookup | db=disconnected | skipping lookup | protocol=%s", + protocol, + ) + return None + + try: + cases = await db_manager.find_cases_by_complaint_protocol( + protocol, collection=CASES_COLLECTION + ) + except Exception as e: + logger.error( + "bypass_history_lookup | query_error | protocol=%s | error=%s", + protocol, e, + ) + return None + + found_statuses = [((c.get("processing") or {}).get("status"), c.get("transactionId")) for c in cases] + logger.info( + "bypass_history_lookup | protocol=%s | collection=%s | docs_found=%d | statuses=%s", + protocol, + CASES_COLLECTION, + len(cases), + found_statuses, + ) + + for case in cases: + status = (case.get("processing") or {}).get("status") + transaction_id = case.get("transactionId") + if status in BYPASS_STATUSES: + logger.info( + "bypass_history_lookup | eligible_case_found | protocol=%s | " + "prior_transaction_id=%s | prior_status=%s", + protocol, transaction_id, status, + ) + return transaction_id + + logger.info( + "bypass_history_lookup | no_eligible_case | protocol=%s | collection=%s | " + "docs_checked=%d | bypass_statuses=%s", + protocol, CASES_COLLECTION, len(cases), sorted(BYPASS_STATUSES), + ) + return None diff --git a/src/infrastructure/oci/autonomous/connection.py b/src/infrastructure/oci/autonomous/connection.py new file mode 100644 index 0000000..fc316ea --- /dev/null +++ b/src/infrastructure/oci/autonomous/connection.py @@ -0,0 +1,202 @@ +import re +from urllib.parse import quote_plus +from motor.motor_asyncio import AsyncIOMotorClient +from src.core.config import settings +from src.core.logging import get_logger +import time + +logger = get_logger(__name__) + +def _build_connection_url() -> str: + """ + Builds the connection URL for the database. + If AUTONOMOUS_NOSQL_USER and AUTONOMOUS_NOSQL_PASSWORD are available, reconstructs the URL + with credentials properly URL-encoded. + """ + if settings.AUTONOMOUS_NOSQL_USER and settings.AUTONOMOUS_NOSQL_PASSWORD and settings.AUTONOMOUS_NOSQL_HOST: + encoded_user = quote_plus(settings.AUTONOMOUS_NOSQL_USER) + encoded_password = quote_plus(settings.AUTONOMOUS_NOSQL_PASSWORD) + db_name = settings.AUTONOMOUS_NOSQL_DB_NAME + host = settings.AUTONOMOUS_NOSQL_HOST + url = ( + f"mongodb://{encoded_user}:{encoded_password}@{host}:27017/{db_name}" + "?authMechanism=PLAIN&authSource=$external" + "&ssl=true&retryWrites=false&loadBalanced=true" + ) if not settings.USE_AUTONOMUS_NOSQL_LOCAL else ( + f"mongodb://{encoded_user}:{encoded_password}@{host}:27017/{db_name}" + "?authSource=admin" + ) + return url + + return settings.AUTONOMOUS_NOSQL_URL + + +class AutonomousManager: + def __init__(self): + self.client = None + self.db = None + self.collection = None + + async def connect(self): + try: + url = _build_connection_url() + if not url: + logger.warning("AUTONOMOUS_NOSQL_URL and related settings are missing. Database connection skipped.") + return + + masked_url = re.sub(r':([^@/]+)@', r':****@', url) + logger.info(f"Connecting to Autonomous Database at: {masked_url}") + + self.client = AsyncIOMotorClient(url, serverSelectionTimeoutMS=5000) + self.db = self.client[settings.AUTONOMOUS_NOSQL_DB_NAME] + self.collection = self.db[settings.AUTONOMOUS_NOSQL_COLLECTION] + + # Verify connection + await self.client.admin.command("ping") + logger.info( + f"Connected to Autonomous Database: db={settings.AUTONOMOUS_NOSQL_DB_NAME} | col={settings.AUTONOMOUS_NOSQL_COLLECTION}" + ) + except Exception as e: + logger.error(f"Could not connect to Autonomous Database: {e}") + raise + + async def close(self): + if self.client is not None: + self.client.close() + logger.info("Autonomous Database connection closed") + + def _resolve_collection(self, collection: str | None = None): + """Returns the requested collection or the default one bound at connect().""" + if self.client is None or self.db is None: + raise RuntimeError("Database not connected") + if collection: + return self.db[collection] + if self.collection is None: + raise RuntimeError("Default collection not initialized") + return self.collection + + async def save_data(self, key_field: str, key_value: str, data: dict, collection: str | None = None): + """Generic save method. Pass `collection` to target a non-default collection.""" + col = self._resolve_collection(collection) + start_time = time.time() + await col.update_one( + {key_field: key_value}, + {"$set": data}, + upsert=True, + ) + logger.info(f"Persistence completed for {key_field}={key_value} in {round(time.time() - start_time, 3)}s") + + async def get_data(self, key_field: str, key_value: str, collection: str | None = None) -> dict: + """Generic get method. Pass `collection` to target a non-default collection.""" + col = self._resolve_collection(collection) + result = await col.find_one({key_field: key_value}, {"_id": 0}) + return result + + async def find_cases_by_complaint_protocol(self, protocol: str, collection: str | None = None) -> list[dict]: + """Returns all case documents for a given complaintProtocol (transactionId + processing only).""" + col = self._resolve_collection(collection) + cursor = col.find( + {"complaint.complaintProtocol": protocol}, + {"_id": 0, "transactionId": 1, "processing": 1}, + ) + return await cursor.to_list(length=None) + + async def update_fields( + self, + key_field: str, + key_value: str, + set_fields: dict | None = None, + unset_fields: list[str] | None = None, + collection: str | None = None, + ) -> int: + """Combined $set / $unset update. Returns modified_count.""" + col = self._resolve_collection(collection) + update_doc: dict = {} + if set_fields: + update_doc["$set"] = set_fields + if unset_fields: + update_doc["$unset"] = dict.fromkeys(unset_fields, "") + if not update_doc: + return 0 + start_time = time.time() + result = await col.update_one({key_field: key_value}, update_doc) + logger.info( + f"Update completed for {key_field}={key_value} | " + f"set={list((set_fields or {}).keys())} | unset={unset_fields or []} | " + f"modified={result.modified_count} | took={round(time.time() - start_time, 3)}s" + ) + return result.modified_count + +class LocalAutonomusManager: + + def __init__(self): + self.client = None + self.db = None + self.collection = None + + async def connect(self): + try: + url = _build_connection_url() + if not url: + logger.warning("AUTONOMOUS_NOSQL_URL and related settings are missing. Database connection skipped.") + return + + masked_url = re.sub(r':([^@/]+)@', r':****@', url) + logger.info(f"Connecting to Autonomous Database at: {masked_url}") + + self.client = AsyncIOMotorClient(url, serverSelectionTimeoutMS=5000) + self.db = self.client[settings.AUTONOMOUS_NOSQL_DB_NAME] + self.collection = self.db[settings.AUTONOMOUS_NOSQL_COLLECTION] + + # Verify connection + await self.client.admin.command("ping") + logger.info( + f"Connected to Autonomous Database: db={settings.AUTONOMOUS_NOSQL_DB_NAME} | col={settings.AUTONOMOUS_NOSQL_COLLECTION}" + ) + except Exception as e: + logger.error(f"Could not connect to Autonomous Database: {e}") + raise + + async def close(self): + if self.client is not None: + self.client.close() + logger.info("Autonomous Database connection closed") + + def _resolve_collection(self, collection: str | None = None): + """Returns the requested collection or the default one bound at connect().""" + if self.client is None or self.db is None: + raise RuntimeError("Database not connected") + if collection: + return self.db[collection] + if self.collection is None: + raise RuntimeError("Default collection not initialized") + return self.collection + + async def save_data(self, key_field: str, key_value: str, data: dict, collection: str | None = None): + """Generic save method. Pass `collection` to target a non-default collection.""" + col = self._resolve_collection(collection) + start_time = time.time() + await col.update_one( + {key_field: key_value}, + {"$set": data}, + upsert=True, + ) + logger.info(f"Persistence completed for {key_field}={key_value} in {round(time.time() - start_time, 3)}s") + + async def get_data(self, key_field: str, key_value: str, collection: str | None = None) -> dict: + """Generic get method. Pass `collection` to target a non-default collection.""" + col = self._resolve_collection(collection) + result = await col.find_one({key_field: key_value}, {"_id": 0}) + return result + + async def find_cases_by_complaint_protocol(self, protocol: str, collection: str | None = None) -> list[dict]: + """Returns all case documents for a given complaintProtocol (transactionId + processing only).""" + col = self._resolve_collection(collection) + cursor = col.find( + {"complaint.complaintProtocol": protocol}, + {"_id": 0, "transactionId": 1, "processing": 1}, + ) + return await cursor.to_list(length=None) + + +db_manager = AutonomousManager() diff --git a/src/infrastructure/oci/autonomous/memory_manager.py b/src/infrastructure/oci/autonomous/memory_manager.py new file mode 100644 index 0000000..e3c0d6e --- /dev/null +++ b/src/infrastructure/oci/autonomous/memory_manager.py @@ -0,0 +1,116 @@ +from datetime import datetime, timezone +from typing import Any, Dict, Optional +from src.infrastructure.oci.autonomous.connection import db_manager +from src.core.logging import get_logger +from src.core.config import settings + +logger = get_logger(__name__) + +MEMORY_COLLECTION = settings.AUTONOMOUS_NOSQL_MEMORY_COLLECTION +TTL_DAYS = 30 + +async def get_memory(protocol: str) -> Optional[Dict[str, Any]]: + """ + Retrieves cached enrichment data for a given protocol. + Returns None if no data is found or if the TTL (30 days) has expired. + """ + if db_manager.db is None: + logger.warning("Database not connected. Skipping memory lookup.") + return None + + try: + collection = db_manager.db[MEMORY_COLLECTION] + doc = await collection.find_one({"complaintProtocol": protocol}, {"_id": 0}) + + if not doc: + return None + + cached_at = doc.get("cached_at") + if cached_at: + # Ensure cached_at is an offset-aware datetime object + if isinstance(cached_at, str): + cached_at = datetime.fromisoformat(cached_at.replace("Z", "+00:00")) + + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + + delta = datetime.now(timezone.utc) - cached_at + if delta.days >= TTL_DAYS: + logger.info(f"Memory found for protocol {protocol} but TTL expired ({delta.days} days).") + return None + + return doc + except Exception as e: + logger.error(f"Error while retrieving memory for protocol {protocol}: {e}") + return None + +async def save_state_to_memory(final_state: Dict[str, Any]) -> bool: + """ + Extracts enrichment data from the agent's final state and persists it + to the memory cache so subsequent runs of the same protocol can short-circuit. + + Persists only the enrichment fields that have meaningful content, namely: + imdb_access_data, speech_analytics, and relevant_documents (when not in + error state). Returns True if any data was written, False otherwise. + """ + req_context = final_state.get("metadata", {}).get("request_context", {}) + protocol = ( + req_context.get("complaint", {}).get("complaintProtocol") + or final_state.get("metadata", {}).get("transaction_id") + ) + if not protocol: + logger.warning("No protocol found in final state. Skipping memory save.") + return False + + cache_data: Dict[str, Any] = {} + if "imdb_access_data" in req_context: + cache_data["imdb_access_data"] = req_context["imdb_access_data"] + + speech = req_context.get("speech_analytics") + if speech and speech.get("motivo_reclamacao"): + cache_data["speech_analytics"] = speech + + relevant_docs = req_context.get("relevant_documents") + if ( + relevant_docs + and relevant_docs.get("documents") + and not relevant_docs.get("_error") + ): + cache_data["relevant_documents"] = relevant_docs + + if not cache_data: + logger.debug(f"No cacheable data for protocol {protocol}. Skipping.") + return False + + return await save_memory(protocol, cache_data) + + +async def save_memory(protocol: str, data: Dict[str, Any]) -> bool: + """ + Saves or updates enrichment data for a given protocol in the 'memory' collection. + Automatically adds a 'cached_at' timestamp. + """ + if db_manager.db is None: + logger.warning("Database not connected. Skipping memory save.") + return False + + try: + collection = db_manager.db[MEMORY_COLLECTION] + + # Prepare document with protocol and timestamp + document = { + "complaintProtocol": protocol, + "cached_at": datetime.now(timezone.utc), + **data + } + + await collection.update_one( + {"complaintProtocol": protocol}, + {"$set": document}, + upsert=True + ) + logger.info(f"Successfully saved data to memory for protocol {protocol}.") + return True + except Exception as e: + logger.error(f"Error while saving memory for protocol {protocol}: {e}") + return False diff --git a/src/infrastructure/streaming/__pycache__/client.cpython-313.pyc b/src/infrastructure/streaming/__pycache__/client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9997f6f1cbcd9f2c4672c59d45466baaeb8a756f GIT binary patch literal 6666 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 E02ru&+5i9m literal 0 HcmV?d00001 diff --git a/src/infrastructure/streaming/__pycache__/consumer.cpython-313.pyc b/src/infrastructure/streaming/__pycache__/consumer.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4b30cd5486bc351682d7da88f80e56cd86b45076 GIT binary patch literal 9212 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 literal 0 HcmV?d00001 diff --git a/src/infrastructure/streaming/__pycache__/debug_producer.cpython-313.pyc b/src/infrastructure/streaming/__pycache__/debug_producer.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..73afd34dd0e41e6560f55d37cfb7d613e218435e GIT binary patch literal 3115 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 literal 0 HcmV?d00001 diff --git a/src/infrastructure/streaming/__pycache__/producer.cpython-313.pyc b/src/infrastructure/streaming/__pycache__/producer.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..26b6ce25d496bf44f1659f0db64a92837d2de4fb GIT binary patch literal 3239 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 literal 0 HcmV?d00001 diff --git a/src/infrastructure/streaming/client.py b/src/infrastructure/streaming/client.py new file mode 100644 index 0000000..b87dc5a --- /dev/null +++ b/src/infrastructure/streaming/client.py @@ -0,0 +1,107 @@ +import ssl +import os +import logging +import urllib3 +import socket + +logger = logging.getLogger("agent-service") + +_verify_ssl_env = os.environ.get("VERIFY_SSL", "True").lower() +if _verify_ssl_env in ("false", "0", "no"): + ssl._create_default_https_context = ssl._create_unverified_context + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + _original_sslcontext_init = ssl.SSLContext.__init__ + def _patched_sslcontext_init(self, *args, **kwargs): + if _original_sslcontext_init is not object.__init__: + _original_sslcontext_init(self, *args, **kwargs) + self.check_hostname = False + self.verify_mode = ssl.CERT_NONE + ssl.SSLContext.__init__ = _patched_sslcontext_init + logger.warning("[SSL] Verificação SSL desabilitada globalmente (VERIFY_SSL=False)") + +import oci +from src.core.config import settings + +def install_dns_override(endpoint: str, ip_override: str | None): + if not ip_override: + return + + # Extract hostname + target_host = endpoint + if "://" in target_host: + target_host = target_host.split("://", 1)[1] + target_host = target_host.split("/", 1)[0] + if ":" in target_host: + target_host = target_host.split(":", 1)[0] + + if hasattr(socket, "_original_getaddrinfo"): + original_getaddrinfo = socket._original_getaddrinfo + else: + original_getaddrinfo = socket.getaddrinfo + socket._original_getaddrinfo = original_getaddrinfo + + logged_dns_overrides: set[str] = set() + + def patched_getaddrinfo(host: str, port, family=0, type=0, proto=0, flags=0): + if host == target_host: + if target_host not in logged_dns_overrides: + logger.debug(f"Fixed DNS resolution for {target_host} to {ip_override}") + logged_dns_overrides.add(target_host) + return original_getaddrinfo(ip_override, port, family, type, proto, flags) + return original_getaddrinfo(host, port, family, type, proto, flags) + + socket.getaddrinfo = patched_getaddrinfo + logger.info(f"DNS override active: {target_host} -> {ip_override}") + +class OciStreamingClient: + def __init__(self): + self._client = None + self._config = None + self._signer = None + + @property + def client(self) -> oci.streaming.StreamClient: + if self._client is None: + self.connect() + return self._client + + def connect(self): + try: + if settings.OCI_RESOLVE_TO_IP: + install_dns_override(settings.OCI_STREAM_ENDPOINT, settings.OCI_RESOLVE_TO_IP) + + if os.environ.get("OCI_USE_INSTANCE_PRINCIPAL", "false").lower() == "true": + logger.info("Initializing OCI Streaming via Instance Principal...") + self._signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner() + self._config = {"region": settings.OCI_REGION} + self._client = oci.streaming.StreamClient( + config={}, + signer=self._signer, + service_endpoint=settings.OCI_STREAM_ENDPOINT + ) + else: + logger.info("Initializing OCI Streaming via Config File...") + try: + self._config = oci.config.from_file(profile_name=settings.OCI_CONFIG_PROFILE) + except Exception as e: + logger.warning(f"Could not load ~/.oci/config: {e}. Falling back to env variables.") + self._config = { + "user": os.environ.get("OCI_USER", ""), + "key_file": os.environ.get("OCI_KEY_FILE", ""), + "fingerprint": os.environ.get("OCI_FINGERPRINT", ""), + "tenancy": os.environ.get("OCI_TENANCY", ""), + "region": settings.OCI_REGION + } + + self._client = oci.streaming.StreamClient( + config=self._config, + service_endpoint=settings.OCI_STREAM_ENDPOINT + ) + + logger.info(f"OCI StreamClient initialized: {settings.OCI_STREAM_ENDPOINT}") + + except Exception as e: + logger.error(f"Failed to initialize OCI StreamClient: {e}") + +oci_streaming_client = OciStreamingClient() diff --git a/src/infrastructure/streaming/consumer.py b/src/infrastructure/streaming/consumer.py new file mode 100644 index 0000000..abce26f --- /dev/null +++ b/src/infrastructure/streaming/consumer.py @@ -0,0 +1,166 @@ +import logging +import json +import asyncio +import base64 +from typing import Any, Awaitable, Callable, Dict, Tuple + +from oci.streaming.models import CreateCursorDetails, CreateGroupCursorDetails +from pydantic import BaseModel, ValidationError + +from src.api.schemas.anatel_schemas import ( + StreamEnvelope, + TicketRequestEvent, +) +from src.api.schemas.anatel_response_emulator_schemas import ( + ResponseEmulatorRequestEvent, +) +from src.core.config import settings +from src.infrastructure.streaming.client import oci_streaming_client + + +logger = logging.getLogger("agent-service") + + +Dispatcher = Dict[str, Callable[[BaseModel], Awaitable[None]]] + + +# Mapa event_type → modelo Pydantic dos `data` aceitos pelo envelope. +# Usado quando o envelope chega com event_type explícito mas precisamos +# parsear o `data` no schema certo (Pydantic não consegue inferir Union +# pelo campo externo). +_EVENT_MODELS: Dict[str, type[BaseModel]] = { + "checklist": TicketRequestEvent, + "response_emulator": ResponseEmulatorRequestEvent, +} + + +def _parse_envelope(payload: Dict[str, Any]) -> Tuple[str, BaseModel]: + """Decide o event_type da mensagem e devolve `(event_type, event_model)`. + + - Se o payload tem `event_type` + `data`, valida via `StreamEnvelope`. + - Caso contrário (formato legado), assume `checklist` e valida via + `TicketRequestEvent` direto. Quando o CMS migrar 100% para o + envelope, remover esse fallback. + """ + + event_type = payload.get("event_type") + if event_type and "data" in payload: + model = _EVENT_MODELS.get(event_type) + if not model: + raise ValueError(f"Unknown event_type in envelope: {event_type}") + envelope = StreamEnvelope(event_type=event_type, data=model(**payload["data"])) + return envelope.event_type, envelope.data + + logger.debug("Received unenveloped payload — falling back to checklist") + return "checklist", TicketRequestEvent(**payload) + + +class OciConsumer: + def __init__(self, stream_ocid: str, group_name: str): + self.stream_ocid = stream_ocid + self.group_name = group_name + self.instance_name = self._generate_instance_name() + self._running = False + self._cursor = None + self._semaphore = asyncio.Semaphore(settings.OCI_STREAMING_SEMAPHORE_LIMIT) + + @staticmethod + def _generate_instance_name() -> str: + """Gera nome dinâmico da instância: HOSTNAME (K8s) > socket.gethostname()""" + import os + import socket + hostname = os.getenv("HOSTNAME") or socket.gethostname() + return f"agent-bo-{hostname}" + + async def _create_cursor(self): + if not oci_streaming_client.client: + logger.warning("OCI Client not initialized") + return + + try: + details = CreateGroupCursorDetails( + group_name=self.group_name, + instance_name=self.instance_name, + type=CreateGroupCursorDetails.TYPE_TRIM_HORIZON, + commit_on_get=True, + ) + + response = await asyncio.to_thread( + oci_streaming_client.client.create_group_cursor, + stream_id=self.stream_ocid, + create_group_cursor_details=details, + ) + self._cursor = response.data.value + logger.info(f"Consumer cursor created: {self.group_name}") + except Exception as e: + logger.error(f"Cursor creation failed: {e}") + + async def start(self, dispatcher: Dispatcher): + """Loop principal de consumo. Para cada mensagem, escolhe o handler + pelo `event_type` do envelope (com fallback retrocompat para + checklist quando a mensagem chega "crua"). + """ + + if not settings.OCI_REQUEST_STREAM_OCID: + logger.error("OCI_REQUEST_STREAM_OCID missing") + return + + self._running = True + logger.info(f"Consumer started: {self.stream_ocid}") + + while self._running: + try: + if not self._cursor: + await self._create_cursor() + + if not self._cursor: + await asyncio.sleep(10) + continue + + response = await asyncio.to_thread( + oci_streaming_client.client.get_messages, + stream_id=self.stream_ocid, + cursor=self._cursor, + limit=settings.OCI_MESSAGES_LIMIT, + ) + + self._cursor = response.headers.get("opc-next-cursor") + + async def _process_message(msg): + async with self._semaphore: + if not msg.value: + return + try: + decoded = base64.b64decode(msg.value).decode("utf-8") + payload = json.loads(decoded) + logger.info(f"Message received: {msg.key}") + + event_type, event_model = _parse_envelope(payload) + handler = dispatcher.get(event_type) + if handler is None: + logger.error( + f"No handler registered for event_type={event_type}", + extra={"event_key": msg.key}, + ) + return + + await handler(event_model) + except (ValidationError, ValueError) as parse_err: + logger.error( + f"Failed to parse stream message: {parse_err}", + extra={"event_key": msg.key}, + ) + except Exception as e: + logger.error(f"Event processing error: {e}", exc_info=True) + + await asyncio.gather(*[_process_message(msg) for msg in response.data]) + + except Exception as e: + logger.error(f"Streaming poll error: {e}") + await asyncio.sleep(5) + + await asyncio.sleep(1) + + def stop(self): + self._running = False + logger.info("Consumer stopped") diff --git a/src/infrastructure/streaming/debug_producer.py b/src/infrastructure/streaming/debug_producer.py new file mode 100644 index 0000000..92bb2eb --- /dev/null +++ b/src/infrastructure/streaming/debug_producer.py @@ -0,0 +1,41 @@ +""" +LocalDebugProducer — substituto local do OciProducer para debug sem streaming. + +Ativado automaticamente quando ENABLE_OCI_STREAMING=False e DEBUG=True. +Grava cada evento em `debug_events.jsonl` (raiz do projeto) e loga em INFO, +permitindo acompanhar progress events e respostas finais sem tocar na fila do dev. + +Interface idêntica à do OciProducer — injetado via app.state.oci_producer. +""" + +import json +import logging +from datetime import datetime, timezone +from pathlib import Path + +logger = logging.getLogger(__name__) + +_OUTPUT_FILE = Path("debug_events.jsonl") + + +class LocalDebugProducer: + async def send(self, message: dict, key: str = None) -> None: + entry = { + "ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "key": key, + "payload": message, + } + _OUTPUT_FILE.write_text( + _OUTPUT_FILE.read_text(encoding="utf-8") + json.dumps(entry, default=str, ensure_ascii=False) + "\n" + if _OUTPUT_FILE.exists() + else json.dumps(entry, default=str, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + step = message.get("processing", {}).get("current_step", "—") + note = message.get("processing", {}).get("note", "") + logger.info("[LocalDebugProducer] step=%-35s | %s", step, note.splitlines()[-1] if note else "") + + async def send_response(self, event) -> None: + payload = event.model_dump(mode="json", by_alias=True) if hasattr(event, "model_dump") else event + transaction_id = getattr(event, "transactionId", payload.get("transactionId", "unknown")) + await self.send(payload, key=transaction_id) diff --git a/src/infrastructure/streaming/producer.py b/src/infrastructure/streaming/producer.py new file mode 100644 index 0000000..2771909 --- /dev/null +++ b/src/infrastructure/streaming/producer.py @@ -0,0 +1,54 @@ +import json +import logging +import base64 +from oci.streaming.models import PutMessagesDetails, PutMessagesDetailsEntry +from src.infrastructure.streaming.client import oci_streaming_client + +logger = logging.getLogger("agent-service") + +class OciProducer: + def __init__(self, stream_ocid: str): + self.stream_ocid = stream_ocid + + async def send(self, message: dict, key: str = None): + if not oci_streaming_client.client: + logger.warning(f"Client not initialized. Skipping key: {key}") + return None + + try: + raw_payload = json.dumps(message, default=str).encode('utf-8') + encoded_payload = base64.b64encode(raw_payload).encode('ascii') if isinstance(raw_payload, str) else base64.b64encode(raw_payload).decode('ascii') + + encoded_key = base64.b64encode(key.encode('utf-8')).decode('ascii') if key else None + + entry = PutMessagesDetailsEntry( + key=encoded_key, + value=encoded_payload + ) + + details = PutMessagesDetails(messages=[entry]) + + response = oci_streaming_client.client.put_messages( + stream_id=self.stream_ocid, + put_messages_details=details + ) + + logger.info(f"Message sent: {key}") + return response + except Exception as e: + logger.error(f"Streaming send error: {e}") + return None + + async def send_response(self, event): + """ + Sends a TicketResponseEvent (or any Pydantic model) to the OCI stream. + """ + if hasattr(event, "model_dump"): + payload = event.model_dump(mode="json", by_alias=True) + transaction_id = getattr(event, "transactionId", "unknown") + else: + payload = event + transaction_id = event.get("transactionId", "unknown") + + return await self.send(payload, key=transaction_id) + diff --git a/src/providers/__pycache__/langfuse_provider.cpython-313.pyc b/src/providers/__pycache__/langfuse_provider.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..919492d4b24d1dcea4af830a7da97fa3a86d07a8 GIT binary patch literal 4854 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 literal 0 HcmV?d00001 diff --git a/src/providers/__pycache__/llm_provider.cpython-313.pyc b/src/providers/__pycache__/llm_provider.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5239a8c5b4ce4772817014ab2d4c21c6bff5d4c9 GIT binary patch literal 9728 zcma)CS#TW3d7iyb>@Myb7~&$A$AT0IiMIrjAPIt8P*|)&2apHD-2t%VVg~9N@UmTj zORAWXToL4$G0S$!bVXI7pQ2Rdfq9E8r{eMx)~gtsG3+pQoP=+n$yl=elJD=q9t0qj z8?`-s{QdX!-;F0#Rek~Kxxub0zibnPUsI)+yjH>X<R^APxFhrm34sX-Tf#nKJ7;Hh zk;;w{2XpYRlR5d<#a#UBW^VrVFc1HFnHOK@i0_=A`T1DaNZ?$M1<zHns&gS0;%)Ac z@VN-9=4H=F&AD1uyWU=h_J}A<y2f@E7g+vw36l*2HmlAWC;eSQ!kh3NvNZ`P`CEiU zU;uldG+=!M)->toy+QO=ZR&02eIfLPi+!wRvK8}GCnAaJ0f&W#wIymM+Y`0hQJri; zZ)EJ~`UDjpv>vaht{2hz(w40!wroAMxiwLD$i=$X=RLc*A2XcWvi1Cyt??~eFK%w- z^G4U_9pBuKd0*MG_0pEDf4*hw<mOgBZ-1h`MPPdq&54E<;fj;(OSB{!dFi<hp+iWt zQvU2;qAk%x5Law$|BNHrF8>~+;y)p$u4>cMnUo@)KYu}-XWF$)T4AD`mgfzH#r*#9 z*^DmEY3YTmA};8PE*jJ@&Ez@dy2h@G*A%8_G<CZ!GDXgEEQ)HdoAX*mH4gjzB9c6V zCaE+gmd(z^ic{&a6b2fKghrZ85igh5l-B0tj4J9zCYu#iMM=~2iii~$8q1_GpLY3* zk}`Dv)Kn@f>v{%TkqxYe&`nM47B{!cY=%!ZH6_Yw8UTiz(WR@Gtr6n&j4|t%4TEJa zFBsUazL1&~WgT!zHZzmCoK-GpX(cPEH~^rytfXWd3ttwNG?P)WC>0QN9@7d_)Rf{k zW;K9RAf48jXee1_PQgjVL=oG*DauR{F~zKLU7=hAjugwxW2gENQMsX{7HDH)M)%Jv z3>fB=w0QX@pSg6j<r~z2K4n-SQ&YHH>EhVvE5oOT#-^rX{zpVuQJWc%RaG<iiRp3F zeQDW{`AJ3{rejdOX*y43Qikarou|p=EMUC4Vi*~9Mn~CSzPxc%-P26Fg)LW-?Yfk( zJs~E#BTNd+1{rEk*q8&-)IRA<I7o6NoG7_?$&HeSm%Nap)b8UoKcp$u0=yPvo|&p> z$ZRarFm`#WbZC#dfYo&91~Y3mU!Cci&T6t@1`K5m$0-{NOfelGS<^o!-;fOLs-o&M z);`u=G+l-^FU|jtRR>yLzxo3q9`%}Di4%h)nSM#KI0faPB)zsEXNgQu3ro^;hUrE& zqbjOqHB=nEB(XZ+W(~CDdP&mhN>WmpVgS&Hz9+N*Q|X^mjsA51g~YIUk!hR(`Zw^H zq@^<bWloY(nhH)eGHI>Mhy6NB^_Tdt9~>hUYq9y8EQq;yMhB_WnSV`q*wmVLwic=z z^3H}rr1tgELT$t1(Cq=LTBV;k8(2qi<kp1nH|YL6LcpaHS*#NSF5QEgmtVRYij-;= zLAaq#spjW>PUhp|isa!>RG}}#`@$@ch@e(YwaH+jE>T0mu%rm9616Ne6OPuK0T9uc zqR(rpt~}aB8wnvZJvcxEQq08N<BpXqBVEdBsILmAHaBmq6OCD&LM^LsDP+-+88pCT z*&;yIbVkkSvl8T<uBoh^5Jd=)KMO&qrfy1edi+*Zk>w`9hOe4|d70@-TDpQEsdX)n zF@oU44?JdjLTc`gkQBzKbwffct#?C0D~;Ze61JG5M~K?v5CN`?I=^6;Zf(J!FUTJI zh&osw#xOcdwgVYB-wAm$t<$<h)`{G7E$ARgv%2CqW4!5k^y*P$i^7^)XlPlf-=3@A zUO=H?cdlV~p}u*gzAsnb_t@i%_}2ud&u`Ha(SOu#x|srARd0nzE=_>DcZ=ZKSEFIp z1OV1dSqo*Yl);=6STAMIu9(pR7#UHF?%4F=3LyrJn`|GowNpkDT2s@8*tTLu19S-) z*r6)&`uRe2Em&Ybc%ivv@xs#nytAdy-m&bgU()l=jzUxO;`ycSytBE`+-h~V7MfZY z&n>x#leYCPjx4q3oxKlTRZr|Lm;Z@FaEExwnwzG5hK<(m(e`9FdY)yYgdO&$C*k0h zfD6{2lb75H7i6V7>N7)9VqJD$WZI3Jk6r|dTgP}=)F2-!^m<XqbA>44Z1zCiX2}vz zvLa{^at#IowD-Dv2PQ#q;m2t)21>XiT#96QGi4JcxQP<Y!4?*ZR^%}oQ4*ze6^N(; zK4}A-y$F{SvL#HQU$-@;GEX=T!^V<5&?)vx=m}pDDrq0{7t7^u(RRj|64r@uu^(fI zM>nL3F(vF1#Lzrp3`z72sbUO?Se|Bts55Q`E7i2=hX|KIm8zUE9VkbuOqX<7))mt; zqd-9zjFET34pBxziqT@t@aV~5>C8}ka9Fx9dTQuA7cH!scM<;ap$iv>#s<feV?$hg zv0Ca1QP+jROVar0$WS~17T-(FPClOlszIkyhb?DuH*_9km;puB_$*1qiXA5at}h^4 z6u{D7_pi2fzjyY&`XHTeJGs)vesulE#&R;Xth|;#!)`l2_BOA1g<xbQ(3T6d-E-vw zy#;ai?Nc8I+gCfg?(JXcK9uV|l<z!Dplz$2J@*ptOW&9BodZ@w$F_Uj@5jC$%XjR% z?ff@yE9ae5rN|?qvq%$#JHiAA)JmTr8f`_F2cpmxkJ{NWfHQJzP`1v7bh}P-6i+1} zN$WR*gK61xFr_|nGITojMPW7Euw;De*1Nav9mt1wEPHqGl}BCdC3IU?OH9hhcjB|h zqfRzR72-8Ur_6OUT2E7%)4~2?f7H8`R5U%3l-5$PDNYjL8OpYjcqFMAWwZjFi0dzf ze|MgF>h!yw`=rU{idqtiAk3OF)twkj3`xnc^Q;FQmiFX?JrqB6adbF7j?ED+PLiDX z%C^rlimfC}p2uB%QTU?c4qTcEjI`2L3azW;+KR$nVa}0oCxxWr0E|rtLhlu)YMUUI z;b|)o&?P|F*b|OJcGV8%g&^9PiWY+?puZ#>%24J0gmc_+1RXN=G44n>X*-H-+%ZP1 zxFIE6MWN6mY!k`?(j&-FXb^@a_^kw9moP3A$Lsb^p+}e&jyXDoX<O7IcjNHJ7F4(} zH`P>ye22p0t{(Yv+T64L3kM}9Mx0f$5dAv*z=SrZh$RoFY+0^5JLJ4rQ^0K!xvJof zqJ=;tjI&HrwFNzUGv*(jrU@|wuB42$rHsrqMO3cAz!I~XHoseBSTebKDUHGQfYl5w zFUq1mt1)9w7SL&Q!+=Wr$zc;ShAwK?RX$iWG*P~$WyrVVOUh_5pxY6}vdG(k&&C(1 zq~X6QjKRN(`BSA6MuB7n*wglep>W*(r_kNFt#||Dpf<9MW;Wg=s6~Z&hJHKscOsOe z3WI>NzSFkStvTYR>zbThP)rwJ9}F?i`c6y-wqjPDfEpOOky5x<My{H2BV{`2u*g8t z7qW&WuNN0BdF{q1Rbgg_gaVuarb%=qv7=cHccmYv1493our8gX$EPsP><(YoYDe$) zu73OK{hoYB|LxODFT6GS>1w#`iD2_}6*{(2%U_NDam|71A9PZWuQ#^~f9rqV@xE)H z;~!klJI2XkGsxwJ5t){iIoP|SfMX;AAxLR1(;3%PCF)}rsS*JwEmDPtDG@E0!LbF^ z$jm82bXDwS0`ggR4-nHm&dI=Z!DnKI>AZrtgXsVgNJI+Duvch+&0tA<b-LLpQGCfd zmC&=N!H5Xv2<+>~evcyvIb7RTz2OzF_*1X=v9}94szY2n{fX1*>Vj$N>UtU!d^IcH zrk{G7?z!(9Iq!kR!GhEEwKHEm^Tse0PJi|E8)pli&{ALC(^Lr6E&bV>+OoI35U76V z@SBI1X7T}8iXERm-6jN^aIBav-0;qq-ulvAr~{f5XY1Pb2c2Zb8})+&!iNLmi6+Mn z8*Rw9mX`kwQ0@kR@i(TTbb=f#>Bg;G00aze$x4`#K~%<AG{{I&7EL#~Qy{rmGKI>} zFyPkLGxquGDj8;DCF7`!h2(P)5A?Dleye3_YHG)ehsi67zBsAx+!5RPViaFAKcb1E z9-<6{QA|4=e7CO1EH!JoPmPWbo<DE8W*}YWK{Oa{x)2aZ>#%&fF%D4<8a1z^&A>DT zailrfNX<TL1+_@Z4TC{?ajy)HwKhkNHu;H61ClrK(}~<+1L0b~^5$3G{)@%4t2K4s zihd)y+;-@}!5@wN>lZ%!LcV5napaetnnG>IN^MWBw&&i<`P%I(wR>{4d-ApYx4o-@ z@H>a!I=nQS4|Fa&J2zW5bZQQgSP!siSU0FzX)9;2tR+?ZR%UrpDB3=%V*+Nhm6DF) z$*Tx*lb(#(`)#VL!mM_V1&Yw+Z?dG@isLIxeNZ7n+YZ$|QPQ=4>e`d`J=k@^xd)0B z`g5J0RZrPuPq-&ay0%O=3C|wDmf*dg0q-uZb_2X`0-C`}6^4EZx}r_^T;t!H^j&&$ z4-h51Ne{yG9?-KtZaNW6WDuH8rezgQiQJ6J{4Z|z5$%yF$R3d#gU^kqRMcZZ^-^CS zWu#oVTV8f@qe0V2B9vQmAn5B-(Y<5Dt6cCw#bnfLh<lkXFol{n9SDS(j;x|uu5Zc1 zi2AwNX}V!TAnaj!xa0++L?E_lmJ)LAR&y)E^uko8fPikTb|+>vBMJh$U@Js5)=|}! zTgpKcorqg3B};TSNvT=cpqQ<sLk(mV3YH<1XRy}+{Vsm`PmsaD4%NJqc`Nhw)x|Tb zjlJ(({a}B-@r7k)-D<dbxn*ZQyle4np}F(^>0D(0a`*taGt_qX(n|ZDT>GB;v-$QH z@}Yw(q2muuV0d-Icb(t!ecSh)z`dDV)1D9Na*YEYyq2#%w0Q2RUubH*yYIWHdwt)z zdjI8IM=amiPqVak+<oo6?t3$TxBG)_xvu^B)&tAVrqw{hYNU4Q%wGprL)A;&D>XZE zH9PX5XrZC6&=M=u_vY%478=?Lwe63?-k^I;@VeX<&(pmZ6Y(17`CoCKFH7MxddmLI zr3z0bX#?*HstZ4Nne)q>T`QbD3oFF~inJa6CTv&4K$5P@h)pJKA#$e?Tp*f7*_m`8 zcO~7(-ANa6&wjt^teDqX<}ujqWd8D$^loLt7P&3y&6f6<^y+pI)@%SWqRdwbe-T!( z!B!^X;Aty;0sC3PetcWcsk)Lr)qP3Fo`8i14Z3l1MCcaAlJqFHaBg)ZKLg*u`pQK? zsl-x}7Zq51#gj||TX`I}34MdU3;%+du<k)$g;f}c^O)lT#h?8CiSeQM_~7{PD}&-- ze0*qZcyvrWJ$U}?Xj1&u;y3ZSpi&I^nv7=%z?-)Ig_rSt)jx;^L^r{+u)Gn$$>1p> z{?Y5B;`~Sd2!kKehoU>slW7^o_nao4ON_?FYae|-i`a3@Pez!S)+`uySX?+rtKk9D zXHf3oIGHk^!9-wvMWh!C+0gpQqKzK*A3b{1zi@^IA3uKFLZD*G!HbAoBxvSmCf<#O z!AXI-)$kMooy!*wn|}e%BzS7lW4A^T!LY{A`XLj<X*rXHpCC_Tn_@2tmW0q_;(1v& zEYtt6*sF_Q7V&mu6jk@J-q^t2z3kQnLTE)Bfd#Tr#t~RE>{v$yto^;|C?Sc7WAvCq z^Ky>_Vl$JP#Xw86#cu5?2`uWDcr5g=IGvfp4lKU~UXBiLOHOOi02gDB#Iic0a9hHX zn{@Z=Dl#)n_i{=}!As+YvFXlgGl1phwduJoGqRu=y&$d&Z1!au7f^1@<6($fEM_$s zKCo<V6s4zbhEfX*uU1A;Bv<pJxh$s<uC}7$W{5X&Dv>ZD6o+-Nrndy*EA?(Ey=%8P z3-%2x?+5tl&q1Co3cm{q!N%3__WO=}_`tIFz{C31#du-+E(BH!1BVvl|5e+x+`RK= zwYzS63W1uXm)}0J+`2p0dhlUw^U{tr!Pa`TaP;Iq%l|a;;Mk9C%jYJRhbPwr`>VF@ zhu!;@yPiXNf78RJ=yKyu0PjlL)&lP8S8Qtzp{n(1olv*?{>gmpOSipi_F7+PwfoqE z*_Gq*-0^t6d-V3%kAq^Nw)L&i$DIItddemQ8kZYS<^4m;&Y@4A_6m`@m2h`1-2Fs| z_(BD7$BMW&C+=Miwinv_ZpUE@-j{PN{rPbJYNX*?!FPj&I;Bw8Tc~R<)U~bD_2lY$ zp88zXHEThkx?wd~^G^KDcp)McB8_*qy&EV*wiP1HpZHu=q2CI~{<zkNS^l6?==;M+ z>);Xlhezs8##}!P57wQGx;~1!P=PlD*|sS@<Ss{$IC)gWkG26kYCru5DyBq1WRh3F zy@e`5gqoIRZ&ObVW#rOviy?Y|4HE!f#ikeNeGLI8LdN|(i-<i$vqYvd)cz`E*Jwzf z^b84ufut&&$eZ&7NNR+UH^MyB+_~Y|D+cp>Ix`b1nKu;fVb=+?eIqDrhw>{lw}Z?u zUc`WcDeHJl9~{Z^7GLq_h1-OVp8_2ZH>a$?;0RUQXr88%=LZvs;nTw>A)QC#5)Z6W zAlB^IqNgGnYqoFEUk=G`iFkbUqIB^ydq&Kf&9CB{moYeYW~gGBJsX!%>aSdE_h&*? zEP2z8%c%ImKfiy(Y}&Z|;^VzpJw7;`kVZ~ath=$YsdA|el^qqUdj^MflAD@F@IqYv z{{U_%TjKb2x^NO%8{Uab?@{Xw_&ED}^g@&g`W@tO<ZZU!+JiRd|FsFW?q3U`p9_JX zQ||k@5JCAD!nXeq`f@_wZ-kS-5DxrC=y~XitoT}f>T9`sFz@SGbU$_r_P|n4&er&e z-DPWB6OcV|2==BmUX7sl6MMvlM>Ay95m=+@Cw&oH^fB6^kNbrBw!0(C(PIx-zV5_g z@OCOMocPcB-G#=^mBxd)#)E}O(@NxpT;zp9xN#*skP8nK{5327T{-`*QsHsH-C$o9 zY92>~w*7qM@m%9^KJr*Da*U5Wk_#W<1NP<o`$`3jY{SSVj12ejk^6Fu`}oMcxyW8V zGL{R+_<)|Azo%5d$gq7e@Y|YZTj$e*E}Qr?Qg3swJuftO;H_=V*Ka#*EBFvCp~Ayp gc+Euxyf4-?ta*6R3j^JPTk{L<>fZ&Zl^fOn4;bO<od5s; literal 0 HcmV?d00001 diff --git a/src/providers/langfuse_provider.py b/src/providers/langfuse_provider.py new file mode 100644 index 0000000..a838a9a --- /dev/null +++ b/src/providers/langfuse_provider.py @@ -0,0 +1,112 @@ +""" +Local Langfuse client wrapper with enriched error handling. + +The get_prompt_from_langfuse() method from agent_framework.prompts silently +captures all exceptions without indicating the root cause of the failure. +This module replaces that function with a wrapper that: + - Distinguishes between authentication errors, prompt not found, and network errors. + - Logs the root cause in a structured way. + - Supports retrieval by label (e.g. "production") instead of version "latest". +""" + +import langfuse +from typing import Optional, Tuple +from src.core.logging import get_logger +from src.core.config import settings + +logger = get_logger(__name__) + + +def _build_httpx_client(): + """Mirror setup_observer's behavior: honor settings.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE.""" + cert_path = settings.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE + if not cert_path: + return None + try: + import httpx + return httpx.Client(verify=cert_path) + except Exception as exc: + logger.warning("Failed to build httpx client for Langfuse provider: %s", exc) + return None + + +def get_prompt_with_config_from_langfuse( + prompt_name: str, + label: str = "production", +) -> Tuple[Optional[str], dict]: + """ + Fetches a text prompt + its `config` dict from Langfuse. + + Langfuse permite anexar um objeto JSON `config` ao lado do corpo do + prompt (editável pela mesma UI). Usamos isso para guardar tuning + parameters que o squad de prompt-engineering pode ajustar sem deploy + (ex.: tamanho de janelas de histórico, thresholds, flags). + + Args: + prompt_name: Nome do prompt registrado no Langfuse. + label: Label a recuperar (default: "production"). + + Returns: + Tupla `(content, config)`. `content=None` quando o prompt não foi + encontrado ou houve erro de rede/auth — nesses casos o caller deve + cair em fallback local. `config` é `{}` quando o prompt não tem + config setado. + """ + + try: + client_kwargs = { + "public_key": settings.LANGFUSE_PUBLIC_KEY, + "secret_key": settings.LANGFUSE_SECRET_KEY, + "host": settings.LANGFUSE_BASE_URL, + } + httpx_client = _build_httpx_client() + if httpx_client is not None: + client_kwargs["httpx_client"] = httpx_client + client = langfuse.Langfuse(**client_kwargs) + except Exception as e: + logger.error( + "Failed to initialize the Langfuse client. Check LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and LANGFUSE_BASE_URL. " + f"Error: {e}" + ) + return None, {} + + try: + prompt_obj = client.get_prompt(name=prompt_name, label=label) + except Exception as e: + logger.error( + f"Unexpected error while fetching prompt '{prompt_name}' from Langfuse: " + f"{type(e).__name__}: {e}" + ) + return None, {} + + # The Langfuse v3 SDK returns different types depending on the prompt type. + # For text prompts (type="text"), the content is in the .prompt attribute. + if prompt_obj is None: + logger.warning(f"Langfuse returned None for prompt '{prompt_name}'.") + return None, {} + + # Try both known SDK attributes to ensure version compatibility. + content = getattr(prompt_obj, "prompt", None) or getattr(prompt_obj, "content", None) + + if not content: + logger.warning( + f"Prompt '{prompt_name}' found but has no content. " + f"Available attributes: {[a for a in dir(prompt_obj) if not a.startswith('_')]}" + ) + return None, {} + + # `config` é opcional no Langfuse. Normalizamos para dict para que o + # caller possa sempre fazer `config.get(...)` sem checar tipo. + raw_config = getattr(prompt_obj, "config", None) + config = raw_config if isinstance(raw_config, dict) else {} + + return content, config + + +def get_prompt_from_langfuse( + prompt_name: str, + label: str = "production", +) -> Optional[str]: + """Backwards-compatible wrapper que ignora o `config` do prompt.""" + content, _config = get_prompt_with_config_from_langfuse(prompt_name, label) + return content diff --git a/src/providers/llm_provider.py b/src/providers/llm_provider.py new file mode 100644 index 0000000..f7d793b --- /dev/null +++ b/src/providers/llm_provider.py @@ -0,0 +1,216 @@ +""" +Backoffice LLM provider adapter. + +This module uses this framework version's real LLM entrypoint: + + agent_framework.llm.providers.create_llm + +The backoffice domain still needs the historical objects +``classification_llm``, ``classification_large_llm`` and ``tais_kb_llm`` with +attributes such as ``eligibleModel_name`` because the original nodes reference +those attributes for telemetry. They are lightweight descriptors; execution is +performed by the framework provider selected by ``LLM_PROVIDER``. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import threading +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from agent_framework.config.settings import settings as fw_settings +from agent_framework.llm.providers import create_llm + +from src.core.config import settings + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class BackofficeLLMDescriptor: + eligibleModel_name: str + temperature: float + max_tokens: int + top_p: float = 1.0 + top_k: float = 0.0 + + +@dataclass +class LLMResponse: + content: str + model: str = "" + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + finish_reason: str = "" + latency_ms: float = 0.0 + parsed_json: Optional[Any] = None + + @property + def usage(self) -> Dict[str, int]: + return { + "input": self.prompt_tokens, + "output": self.completion_tokens, + "total": self.total_tokens, + } + + +class _SettingsProxy: + """Proxy over framework settings with per-call model parameters.""" + + def __init__(self, base: Any, llm: BackofficeLLMDescriptor): + self._base = base + self.LLM_PROVIDER = getattr(settings, "LLM_PROVIDER", None) or getattr(base, "LLM_PROVIDER", "oci_openai") + # The framework supports oci_openai, oci_sdk, openai_compatible and mock. + # If a legacy env uses "oci", route it to the immediately usable OCI + # OpenAI-compatible implementation. + if self.LLM_PROVIDER == "oci": + self.LLM_PROVIDER = "oci_openai" + self.OCI_GENAI_MODEL = str(llm.eligibleModel_name) + self.LLM_TEMPERATURE = float(llm.temperature) + self.LLM_MAX_TOKENS = int(llm.max_tokens) + + def __getattr__(self, name: str) -> Any: + return getattr(self._base, name) + + +LLM_ENDPOINT: str = getattr(fw_settings, "OCI_GENAI_BASE_URL", "") + +classification_llm = BackofficeLLMDescriptor( + eligibleModel_name=settings.CLASSIFICATION_LLM_MODEL, + temperature=settings.CLASSIFICATION_LLM_TEMPERATURE, + max_tokens=settings.CLASSIFICATION_LLM_MAX_TOKENS, + top_p=settings.CLASSIFICATION_LLM_TOP_P, + top_k=settings.CLASSIFICATION_LLM_TOP_K, +) + +classification_large_llm = BackofficeLLMDescriptor( + eligibleModel_name=settings.CLASSIFICATION_LARGE_LLM_MODEL, + temperature=settings.CLASSIFICATION_LARGE_LLM_TEMPERATURE, + max_tokens=settings.CLASSIFICATION_LARGE_LLM_MAX_TOKENS, + top_p=settings.CLASSIFICATION_LARGE_LLM_TOP_P, + top_k=settings.CLASSIFICATION_LARGE_LLM_TOP_K, +) + +tais_kb_llm = BackofficeLLMDescriptor( + eligibleModel_name=settings.TAIS_KB_LLM_MODEL, + temperature=settings.TAIS_KB_LLM_TEMPERATURE, + max_tokens=settings.TAIS_KB_LLM_MAX_TOKENS, + top_p=settings.TAIS_KB_LLM_TOP_P, + top_k=settings.TAIS_KB_LLM_TOP_K, +) + + +def _run_coro_sync(coro): + """Run async framework providers from the original sync node helpers. + + Some original backoffice nodes are async but call this helper synchronously. + If there is an active event loop, run the coroutine in a short-lived thread + with its own loop to avoid ``asyncio.run() cannot be called`` errors. + """ + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + + result: dict[str, Any] = {} + + def runner() -> None: + try: + result["value"] = asyncio.run(coro) + except BaseException as exc: # noqa: BLE001 + result["error"] = exc + + t = threading.Thread(target=runner, daemon=True) + t.start() + t.join() + if "error" in result: + raise result["error"] + return result.get("value") + + +def _extract_json_from_content(content: str) -> Any: + stripped = content.strip() + fence_match = re.search(r"```(?:json)?\s*(.*?)\s*```", stripped, re.DOTALL) + if fence_match: + stripped = fence_match.group(1).strip() + return json.loads(stripped) + + +def _chat_llm_single_attempt(llm: BackofficeLLMDescriptor, prompt: str) -> LLMResponse: + started = time.time() + runtime_settings = _SettingsProxy(fw_settings, llm) + provider = create_llm(runtime_settings) + messages = [{"role": "user", "content": prompt}] + content = _run_coro_sync(provider.ainvoke(messages)) or "" + latency_ms = round((time.time() - started) * 1000, 2) + + # Token counts are collected inside the framework provider when available. + # This compatibility response keeps domain code stable even when the provider + # only returns text. + estimated_prompt = max(1, len(prompt) // 4) + estimated_completion = max(1, len(content) // 4) + return LLMResponse( + content=str(content), + model=str(llm.eligibleModel_name), + prompt_tokens=estimated_prompt, + completion_tokens=estimated_completion, + total_tokens=estimated_prompt + estimated_completion, + finish_reason="stop", + latency_ms=latency_ms, + ) + + +def chat_llm_with_usage( + llm: BackofficeLLMDescriptor, + prompt: str, + expect_json: bool = False, + json_max_attempts: int = 3, +) -> LLMResponse: + if not expect_json: + return _chat_llm_single_attempt(llm, prompt) + + current_prompt = prompt + last_bad_response: Optional[str] = None + last_parse_err: Optional[json.JSONDecodeError] = None + + for attempt in range(json_max_attempts): + if attempt > 0 and last_bad_response is not None: + current_prompt = ( + f"{prompt}\n\n" + f"[TENTATIVA ANTERIOR FALHOU — tentativa {attempt + 1}/{json_max_attempts}]\n" + f"A resposta anterior NÃO pôde ser parseada como JSON válido.\n" + f"Erro do parser: {last_parse_err}\n" + f"Resposta anterior (NÃO repita esse formato/erro):\n" + f"<<<\n{last_bad_response}\n>>>\n" + f"Responda APENAS com JSON válido, sem texto extra antes ou depois." + ) + + response = _chat_llm_single_attempt(llm, current_prompt) + try: + response.parsed_json = _extract_json_from_content(response.content) + return response + except json.JSONDecodeError as parse_err: + last_bad_response = response.content + last_parse_err = parse_err + if attempt == json_max_attempts - 1: + logger.error( + "LLM JSON parsing failed after %d attempts. Last error: %s | last_content=%.500r", + json_max_attempts, + parse_err, + response.content, + ) + raise + logger.warning( + "LLM returned invalid JSON (attempt %d/%d): %s. Retrying with enriched prompt.", + attempt + 1, + json_max_attempts, + parse_err, + ) + + raise RuntimeError("chat_llm_with_usage: fim de loop inesperado") diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e40643e --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1 @@ +"""Utility functions and helpers.""" diff --git a/src/utils/__pycache__/__init__.cpython-313.pyc b/src/utils/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fc71056da95bea7983cc32fe98341cba1c13b490 GIT binary patch literal 235 zcmYLD!HU8#6iif9g#LmQ(SsKQo|lC^?qNkJdt4qhNj2K$m6vp-{)ZpoFZH@Vu+Sec z1;x3{3=A{VM$v=?tUqSQMc}{AJi#BL9S3n`2_v@W%id-B?M-fc9cPu5-k^1HYE^vD zd8x^*HcQ@&02E4q8*96g=0_CP3njji&v;9C)Y3bV?zQ!hQCjFT(g{!+u}kF%v&={h x678=hZxp^V!jTkJn0EreSmOa!W!;S53RG3D-)R`uy5Fw*{NLxCH*8LWVD9tYL>T}8 literal 0 HcmV?d00001 diff --git a/src/utils/__pycache__/cancellation_helpers.cpython-313.pyc b/src/utils/__pycache__/cancellation_helpers.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d9f5570c72eca641632eff6821e6d2a4246b028d GIT binary patch literal 1923 zcmZux%}*Og6rZ)%Ul@Y{1A)+bi2|)li$$DPX_d5D8ACQ8gFJSWG)Sw>dVr10?sj$^ zC8!7Y)b!R%svdi5dduI@ligH`HKkId9;)745%t_RvvvX~BhCAoH*fd7_j_}kN(~`c zfBiatKN|(kmBC8(h2y6nJV6*?X#-KtLmSe~5Dnc7)3Czz@P>RdLL&;=L6zab^*8(i z+KGKE1v-smnI>?A4&f+G;usyqahk#jI)aDjC{EHe9^M(lshzRQG9H;l_hmZ%F2WOd zbSETE(mNA)47T)93?c9fT^_>Y-0!|bGka1l^AG<}ITvC>>K?Iu>>EB|lTEh{T=%%9 z9}wRF)yPRKu}%$lAN#~TOm9=iB%bFen(Y&6J1nXC#I9Qh5XL#2DjK#)8ivpmZB<ci zh}B?Y&h9R;J?jC{O$RR8K1&+)2ZZ{TXW4sf+#_a_TK*&bf#oy|-*Rkrp=ldUf8U|j zA*t*BzD4W0Yf$hpoddUFSP&#pZ@LZ3gm18z2>j4zQA+$KwatDpMM=Xz`959)0&S9N z(4hA~z6(28U<nIL=!8WU7WsrF!Yws)5#^$XrESM;!rv0b-zN&T$UV|fOi_U#2gRqB z+aSK;*usU9ubRa3gBm>amAYm6%PbyTF(^xUB8G05q5?5W49~GwSYNm_5DK|4%M{ge zv9zgHwXL#VDXAFP%3H0M-fAu6B+7B<@j5B2kUy~fJa2e@*LtX%?rz?rX1?iL4KIH( zCw-qZTtdBN_Ys>lT(<%JIFYj6Pnn*Buv0vK9!Lwl%;s9_kGDGV+)=gwlGc%@-X?XE z<E`>BTtO@%fXMsFtH2Tg2e_E88v<?-*rK&Rfbir*Lo3{RW<ldaBd8D>Oqzo3^7~@d zq=D6zZqEnssD<yrw{EZWp#jq$tV(<!z2N|$04{?!0o|5x<kJv`O8Id9RBNvdTCRMp zR7858DJ&Hj^?tq+feQKdEqqXhVN58D4-6@BVKhh2Kq?ny(T20PM<|mmd)E<dpgc#W z1c1@X>$+N2H}7B#>w!tHYQ>FGmGZLYWI78>K!cv=(i{+vA1SctGzcv7`l}~8PZ!`Q z%blx0TIkg<iX^|?_-3O$UU;_l>`FU*^>_K&(a8Hxr=O3kwAQ=X1%ZcdEZvFCb#s^6 z>4i>w@n~-0#oStFZmm^%yxfszj*`kxlbz)CwtSt=K;WEI&P}oyPm&kpJfL*h;E&j{ zfJ-0;Zy4nW5LvOwvu?b8UQn3C9`QGWn#Fb9hQ~|S=>>i@!^tEk9LX%(FIRusw$|24 zWvtyQ>BX&bwN$RMnbQvH>a9|xs$nQOn>u}jOT}76tKMOYr|q?}TC3jJs%T%9R`u!) zt+J|bs}=B@KJB+!+uqcQYPG~<{)mV+v#{q=(S=`-C{@s`_|*fa-fWQ1X#tYp)9(EY zq!*SX=?^sWH`4w@OD_}Y7m3;560<*Ed7ik~iuU5@+y`xx=}uo1G!L{FMIV0AM%nJt zl{T8`E`dDNUD|4+#qQE4Z8Y7BMx}|Tat{G}mU+clZx+2X|8HVgQl)NkvKQvU%hXse h!Uc$zO7~(yjHC3#_tozXd&0Ljgre#HlKey@@;@5w3*`U+ literal 0 HcmV?d00001 diff --git a/src/utils/__pycache__/decision_helpers.cpython-313.pyc b/src/utils/__pycache__/decision_helpers.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0b2d4d310f54c250c4ed007a64784d3e90016e03 GIT binary patch literal 2849 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 GRs0Pbwd8*Q literal 0 HcmV?d00001 diff --git a/src/utils/__pycache__/external_response_builder.cpython-313.pyc b/src/utils/__pycache__/external_response_builder.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c2eedfd4e6cd441c11223a38bc7c0fb70710694a GIT binary patch literal 3067 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 literal 0 HcmV?d00001 diff --git a/src/utils/__pycache__/forwarding_helpers.cpython-313.pyc b/src/utils/__pycache__/forwarding_helpers.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7d515b9be956e3d21dee9f2f08883a2355cc5945 GIT binary patch literal 8943 zcmbtZTWlNGnI7IGQKBxs$hKr%#*!_GwoEy8+@^Nyl(rO^u`PLdq|}MkB}U{(9*3Nf z&&()|+67c#flZ$3J{0l3CD@0?1&SKj7o$(b>ZSWeRR%;%vw*QJQ1p#~8|>?T{}~QB zq9|Y50XTE6|M~Cd|9?JjZ*P_GoJ|hh`_~ha^e5_g&%sLL`9MIDejzc51+Gh+zQOB( z_kuk5UWkWe>W8j}-;3~wEG<asn76(59g-HB&jSWMyoH5%ltmU=S>SZDB(bK2wl~A9 zxmUUu<}ud7+gX%%uvXs5+ISa>@qMhF?`IwS0PExjSr_kS`}iTYpC4ui_z`xH_pol> z%MS6Q>@e?RM|l6jLDsX-&3YFOv7-xzS>M7D*1yoh1{QkR;KETRFZ8iv3;k^9Oe-H? zC)w~qkR4wbWG8^p2!2fq!8cpkDB*K2z~vqDV?L~DRa{_wY&j4=^;aTpCLVNJlgp}Z zF-x&jr`ul1;hQn3mno>0g02z|IMHdYtlVXmx?&&3KE*b%N0oI&=4v*t6qJo$Zv2Pg zgmtB$I!#=)Y_4Z39HfJ!Q04dh0veD_U|zZu7^4|Hxu7ILF2S^ahdU*muY51!dt8c# zW}MJm>ZT(BsffL@qVjlvH(}1Cy|S}BQP8c4+{8_ml5cV2o|?7H356)kEO8+BvB5ve z7+Gy%QOSN}EG=nSHIp^;Rh3&>&Zw=KFnM;uwzRxCv1IU%6`s@d<;-0*j}y(r$|p{f zW@ap{z}s;wsgT)=*Sb_{>v}Nv`G*hF+XqKB4~}dd9NTOgD~89OV^(sGD2v?67=WQz z2G3|Y;H52TYR(y4F}S5HYI)82q~U^Au~p^V;7+$_Yx=5^*K!%Ink$BGs!oR()(wzq z*m}-s)e5=AjJu@^L|W>PYKPnZ354d<yt-uQswc&qA&(VI&CKa>S%*Mn(5|alaHd|= z@`}P089+@tZz(jJypo<v$XAU5*3#CENAikcnj61fRr9h(X;$H~A`1rO3;<Zw6gj7w zS*{5M8kbFV+17NNmo*BqAzNS+q^4T}0SG>^c5O=M+I<^Xx#KxSUQ|p~#uxK)MKu#L zdl@AAhcDp(#AK;@R+Trt5TFS+LT$%^FE}V+U&jgWD*3yLtPn*mvxoyOvtEWG7{~=j zxT0_TQc2ipz?&uQ5zG^8u5lI&sEQ%&vr3nzeB(=3X1iHx-v`(<X%Xz6_(<5vnobC7 zCRhMVA0wAwo3dg1qz4V$_!25;nDG~|fv_UU)Dts@Q?KSV0p3T25WOLG{GH?!e1O3# z4-~mFqR#KJWVIk?Rej@2t{G57!qGBpD<Qug13$%yGp@a8duY9ZbA_!yfq+ES+;a+! z1y}JkOtLqn;IclOw@q!;Fe{wpds)OTzMd1(ta)Xh@!??DmCM{Z(@WS*@SsT<ng)@& z_T;ln*QTL*1<lfIA4pDY1KI`Q4P2xZ*+#c!<w}0l7?(jNp!~L=a^i)+54@=NHyWrH z;4rt##|T3wF97Q<$m*(=+xV59)f7`UZMbrwOQySxt1w-qpy_uNJ`Ri9A$8$p$Rfzg zG%$$&Yb;N|u*v$k3{klP1D3mKg5LUFk|0C)>N@WSi&FBtX@T7>X&Yazs#-%@gjwc* z2Kgd*$Hk3D1rWH0;9i^d-+=aH)mf46^V0&~tYx9DaKH`4NiN^kXgjeLzS4pYS3E#M zzNO|#63bi}msdA_t!M4LZ;2;eIj4csngKS}$xU#wfxIF@b%`<JHiEM?H8?HTmC1C= zk)W60iGe!8mffamELtk}R8QPRM1w1aylfhaTm>>dl_t^2jie7McX(}5e4@lQU0o%6 z@oO%QsaQ=zY1eV!Wttm$B~Azi<c%!ihc)u(4Rn~u-9x2Z{#9KzIj;sT0B#h%x5lwb zm_mA;mseB>euW#0d9|SIwg4gLf5?LWD709dS-aG#nxL#;hN22ve(}9F?$TQEdt`7` zvSG`9vOQJtlPv7}6_w;Q<xJ6=&r0{u_$sn{2w7YPAWnoibj`bw&Q3#BEUqouZh+>6 zLQrTCB)qnzJ`;G?6wAGGMc^xLLZ5#D@9Dm#OB>3#2rO+VRNVzW?o(bBvRtK@lKcv; zQxDm(1~)tcmWIxV0+s5qbuy_Dix@>QEB8|acl76%5Gl;BdY9%@xE@?oP$=?JxEQ&* zroh9(!PLwLtLQHCk-G<)|A7B_?YrXlb%c_rs=<8CRG@wCec@TWcN>$89-*_OauEi) z>NgHQj9W(BZ^R9e0H^|l8_Z#k-^~1;MNn@kFzweR#)*(CP@QJFyO1eS8Ve!oPN${t zWwn+$Wy7G_dm?Cr@O%C%ynZ3w@y4Yzjn+NLdV?L(xu92DpqzI;z(S)~cc%(9-CVZ~ z-P=Rwkt5Tihgo<&&}a>dcyQ-K^I_JMz&h3}U`O4(fD>RX-wPs-j?SzNA<Ux07Wr>s zC9i0@bqTo&BEO#?`X$OQ$nQG)b}e$E#2`(#74CEvjGUTB21?73AU_H_Eje7>CN6Jh zpb9tRYQ>6$;f$gy`A?8n@E#ylg-_Xah2IA%N79f7>`}n!<SLZNv?yIC`NShmQ{Gr! zR=E?VkkJ{+OwMLzX6G{R&Zd8mOiyMer>>@0YIY`*o=URWnbgd*6M`T)aXKF1hw$Nt z>2-u&0-gQdfi;>;60uAVr##>3K$*bJu?6?|Ners^nTvR>OJ9du|LyuuuNU{f`S|_E z*NR=2o`x@%hR4>Yi?PAYu>5t;#qFMVHhbRrUDH<2Y%zT3*}=im$v1v;>d~p<cc!;a zUMn3vS?ZtoCK?%OqCLkp!$W_INwH&JbZ@qOzZm{L?;s>vgkwMrVTUr3n1wQ~Ls?!; z8KHoow@E3(p7O}jd}vo`CgiFei;z0qKE|5FH|eSuYk{IgX@s?+)h1dowAw|h1FcSK zL2Zebbr0+EPOd-ojMQ-bz6R@20g|OVR0k4|9@*op{jadMX^*`JUSV(ZeDF?-hsA~4 zlB7V(*unWohs3()!+0N>Z^HZVd^6rh&PDYW)+5Gy&xQ2p>^Sy|0w=+E#r;Rc{ywok z#`;BX0KFDADB3d3kc=8~^O#4~e0V;}hJ@1?CTZRNDLYQzJJkYdnpCgunU~lJv1*?; z0T(kOT+Haq+Ob{U<I)^Q=1}u6YwhUFQc?wFz94@~U_>Te4P8)zoa09^>~wh0DvqMd z>wCuF#QZU%({5_&BHWXbCC9VYg?eCCHF0mJ;6S);IL$7gH=MR4BA}dGQ1V94uI*1v z%CjPh6$nzz#hW}wUa;adoqg3NPOyxuk@pQ{OuJA}_$N+hbyeoRZCI-P254eN9%(D` zBm+LTny^+2Tc$hhR##>HzkVyP?RV2#|NXbtKaL^*l0|q%oQ8S~_bylpoz{lNMJ|IJ zT}3TOMy(Sy$1JKvR9{oGcMW-HEjX00KLkR_J%j$LKwjrIr#Kr$WGeO9e*pMOfv{1+ z@q$7?B?{kBQQR}17Bxi#r(Ups5wU{s>Xh_>9ex!Nsja4*23OP{RkAQ*zR1{wRteOr zq(c`)bXW^=6866ft_jvtC3KHS{N)*!KkC_$EY>B;P$N(ziXeLnOF>1LvKxB|*il#s z4I?8H<oM&IotYTv5z5e1ZlxMcokA`dn&4!P>cNUklC42?i=9`IGYdUyn1TC)^-4Vk zki7hZ!gZg%to=Y%kvkV;UDfZ}1-W3jW`SeXRUsg+Uf{#JVlWNSM-T#pQ<L@wuOu;F zCgKaaUy8QS2TJ*El%I%4glv6El92Zn9kiU3$t%2)xCsixQ7Iq#$bD@g@qW<G2k13~ zm(yQyp%iOnQAMx*nUE17a4c(cip=qckh;Q$u_GSkCusKwz3BfNr#pFtP0b+cO3&WR zT%Emn>qas)Gv^F?QJ5QiVP<A->h@e__SRH7IX9bj`s)@@K3NIh=4U23MGDn52;DNt zndFTRSc<u4OwCPAx`AA4eIR#=b~YO;8S!US3vv$ECpUYZI3oP7(~Te!*MLIttNRa1 zreH2R-9DDM_X7=^bSsM}P`pGaC~hV9g-P>&QF>XI5UfSo+z9a8<98pw_2km;kNkJ6 z*fsw&{C=t9(00e^V(;no>C*717ZR3+<HcCtX1KpJe6koD*bEPrhTmA9{&aRT+*cYt zk5+0ke5f>hrrP?h*Xla_dE3w19$w$-8r$wle062J^W0|Vx#FACTb<XoJC&z5l=W++ zj_#j*^w~!^b*m$>-7)?2>@<##9r)Sx&#o7b#J6H6w_{hICa$heJ!@`%y6?<Z^O<KI z@BFUi$y!+oycg&x^~evS#bcMBeEdYi$hE+k(wXyLef&6Ejs)8;mP1l>pd1Ty_LusP zKTMUS(8#HBDBM2&jhN_=TDzVeII|V~J75Mo4?jCR@PH1LFP9^s_IF&ET~g<Vfl}w8 zPif}JSUD7Ik5|zcDUF=|;)AjjoVZ*LVT(J_C3O$iMcG58{<Axx>~c&xGZiStPHl$A zzwSG+-FJ1f4@~(qu^I09x=r2@f8#&L(-3)l$Z4h&1lHpm#UF+V@ke7q<dJUM{U?=& zuvloT(_O!eEX#DYJ&NSyhMUo}WQ6u+GOo3~jr~r@w2<m78b)6H>FD$|GN7t?4cE=y znoZ9oucU6I=02#|U{Bp5VS`iYG|cm8qp8%)`^g)rNq(8|so$KsIe8@`HuFm~KUkNJ zRUE<0YzBYMvdI}|sPO^;=VppAO4tToum*RaBR8J+5pw(mE8J`m?q)As1e^-hd*LG1 z5H2SgpXBE}Ela$788Cu_OW#b*Oik_uc5;ecO{Z?nQGyGwoT6|ijJk;>Qc~fU9!Vly zbQ9N>w~?*edG#{?XDo%YlFZNXE{6hvz#pZKzeuV7kxo5}c5O#{{}Anc_|{hR_<B>h zN$NRUl)B4Jfk6Dhfd|&ldtr5f_!r^7(Wl%hjV1#{sjoCLU6h7PBUsy08o5-Ij+REg zQ<VC>k++J{AYjCA1krdldcP<Qm4njQG`a(&QL8A)rO_W3rQ<Ys2ZKYU(SN}dy7AlS zo<QU5PtZt|#(qfMRw;tDq3Cz1HCk;AmxICR#d3=j!H!V$do&xbw#2&qG*(-8l4gf! z>>RcFsfDp%^q&IdLg2y(;quK|pf!*TJUm&J==+uBexES+TV(H78ux$vtyod+O$M;A u)Ye@NiPp3BedUN~fuZeP<z~0tB6aS6F!|ZNa@6g&N=;qgwu!b7-2Vf-<UP;; literal 0 HcmV?d00001 diff --git a/src/utils/__pycache__/http.cpython-313.pyc b/src/utils/__pycache__/http.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8860df0755a8cab883d8ae60e061245eceafd659 GIT binary patch literal 8268 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 E1r1IVCIA2c literal 0 HcmV?d00001 diff --git a/src/utils/__pycache__/ics_collector.cpython-313.pyc b/src/utils/__pycache__/ics_collector.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..72784d864c18fbd0f106721290c59d20d85214f0 GIT binary patch literal 27664 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 Yef4uEpnFg1T6NC5Bf1WqlPSypH!O`9hX4Qo literal 0 HcmV?d00001 diff --git a/src/utils/__pycache__/loaders.cpython-313.pyc b/src/utils/__pycache__/loaders.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..be47377ce3e514e5111de3e99b2a38abda5e5d04 GIT binary patch literal 743 zcmYLHJ8#oa6h7CF#EA(|kf0z$ZxI3#C|(E;2`ZH`wNgN}GN4qlj32=ve(2hfNX5_* zkpTuOb<9}AA7Eu=3rnR7Ot7#;3J8gbbJMt-YoG5t?|ImhN~H`)-#;yO1_V(kIVm$? zQbS<}OfbnhFoDuKS(BJV!IU>;Q`uB*D5iQAIto+Uq+0kX5*t;*l}C2=l$ZRV>sgT< zdc45Atj8h;OC3@HB^lqz2V^_YI`p-qJf=RgZR9QG4n&hI=>Sa-JR+@P?rIWKGO@Wk zvILRT(sKL8bl_Y&zzfin@*1gRs^ZjV)hS$rMYsl6W+^WA;^XKGK=HKId-4MKIP_M1 z)n?3V+q4(bHS=MEx;9PhG_$Ge(J*$NrPK>)${eGY1>Mw`nhdT^krTG1_A-CzHm*(c zA}4l(sNJX*m{<+hENi+%ix(1`c~QodIPoH`bkZ1kH@4lX%*1xMl8BuXKNX3TQhvz@ zqs(ya%r^Ysx#c9j@xKX?>9O>B(&tBoY>93xaVtB4`Qpa(KC*52rNNQoZ?(blzD`Fv z9o`!0wY|q9{pR+AAEnAm_s#0?^5;@*&;EMs<m>*c{_fo1#UB~USNBUbeEz2Rec=76 z6<z+QmsiddakL6rcv1A*4W1kfDyJFaiW@lDwEjDoU<|N15vkn`W4G7!mf1Y2;+IIz eBOA+vkT0NrgZZ&iB<FUWF(3@x-vZca4EYCmLZ;pT literal 0 HcmV?d00001 diff --git a/src/utils/__pycache__/observer.cpython-313.pyc b/src/utils/__pycache__/observer.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..497c33bd2c441798bcbb22366622ed5ded42cf2e GIT binary patch literal 29099 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 literal 0 HcmV?d00001 diff --git a/src/utils/__pycache__/text.cpython-313.pyc b/src/utils/__pycache__/text.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cdc0e71af0182e63c5e5f583b6315c2260f8353a GIT binary patch literal 833 zcmYjPOK;Oa5MDpx1c#EMJ}8`46+-c$u@sd{R3TLe1P30%^#L_fWY(K>)7XyIYe+aD zamg{4N=R_%5pEp$8L7BbJ|V%KqDVP2YsYFw^2|50GvoPYcfPV>B9P|(cE6`0^jkWG zTw8+qHh>f4lpUpw9JQ@D8d$xJb}LTl4(jWDg{wWos-!5@1s#dBG++S}sSzjLE*qr= zry}7Y(TdJs4fv!#7obj%i|Syd_zFTj<q&i(F1o+OuF`5D<iVIOx{F-3sPNWh#8KU= zRccYS0|a%*-hp;dmF4MxEbaYY^MEHI@dS;rhhxE6*ux@{ct{EBGV0@uNy9Yvc`t66 z7(_B`;e#W3r9v$GG_s7WCQk{x5et$4lOvCNL{N@Nl!PMB%h%JwFNT6ep*O&JAREE= zG3(-@uaE0K?Rv>T#PXB|Lor_d-pYHKud*F4XOz=j*5=F_CWqCb=a!Oc0~%Tym;2|s zR3%Wcbb(*`+73ct`}Q;EDSpYLJ|!Zyy&erkr^~&7zKi&ACnC&#<B{VCZkW&xiNd#( z3+6|cId;s6od`CFZMn0?aGb6bLv>_?%eN;lH+}-@16pjM>#OIrn}5pYWcTBE`sio* z_IYh>Qe7CRas$w8y*_onIH#MR56?F0vzqnw@mZ}ot2BRAYg1#oG1;5xx8~4Gvdq|8 zTB-s4RAXWOyKjhB;hf9IomTdO$WI3J0k46ZO>z&wg{COVKXp^t`lMYTFsBbQ^L;Hd LKgdD`vp)X;wo29; literal 0 HcmV?d00001 diff --git a/src/utils/cancellation_helpers.py b/src/utils/cancellation_helpers.py new file mode 100644 index 0000000..39c9002 --- /dev/null +++ b/src/utils/cancellation_helpers.py @@ -0,0 +1,43 @@ +import logging + +from src.agent.state.agent_state import AgentState, update_state_metadata +from src.agent.state.steps import GraphStep + +logger = logging.getLogger(__name__) + + +# Siebel triplet for Anatel ID cancellation. +CANCELATION_REASON_1 = "Processo Interno" +CANCELATION_REASON_2 = "Atendimento" +CANCELATION_REASON_3 = "Cancelamento ID Anatel" + +# Short identifiers used in tracing/logs. +CANCEL_REASON_OFFENSIVE_CONTENT = "offensive_content" +CANCEL_REASON_ADVERTISING = "advertising" +CANCEL_REASON_SECURITY = "security_violation" +CANCEL_REASON_UNAUTHORIZED_THIRD_PARTY = "unauthorized_third_party_complaint" +CANCEL_REASON_DUPLICATE = "duplicate" + + +def apply_cancellation_triplet(context: dict) -> dict: + """Populate the Siebel cancellation triplet on the request_context dict.""" + context["siebel_action"] = "cancelar" + context["reason1"] = CANCELATION_REASON_1 + context["reason2"] = CANCELATION_REASON_2 + context["reason3"] = CANCELATION_REASON_3 + return context + + +def request_cancellation( + state: AgentState, + context: dict, + reason: str, + reasoning: str | None = None, +) -> AgentState: + logger.info(f"siebel_action=cancelar | Reason: {reason}") + context = apply_cancellation_triplet(context) + context["cancel_reason"] = reason + if reasoning is not None: + context["canceling_reasoning"] = reasoning + state["current_step"] = GraphStep.CANCELING_ANALYSIS_CANCEL_TICKET + return update_state_metadata(state, request_context=context) diff --git a/src/utils/decision_helpers.py b/src/utils/decision_helpers.py new file mode 100644 index 0000000..5406251 --- /dev/null +++ b/src/utils/decision_helpers.py @@ -0,0 +1,82 @@ +"""Pure builders and Siebel-triplet helpers for agent decisions.""" + +BYPASS_AUDIT_MSG = ( + "Validação não aplicada devido à presença de reclamação anterior " + "de mesmo protocolo já validada" +) + +CANCEL_REASON_OFFENSIVE_CONTENT = "Conteúdo Ofensivo" +CANCEL_REASON_ADVERTISING = "Propaganda" +CANCEL_REASON_SECURITY = "Violação de Segurança" +CANCEL_REASON_UNAUTHORIZED_THIRD_PARTY = "Reclamação por Terceiro Não Autorizado" +CANCEL_REASON_DUPLICATE = "Duplicidade" + + +def build_canceling_decision( + decision: str, + reasoning: str | None, + cancel_reason: str | None = None, + duplicate_match: dict | None = None, +) -> dict: + if decision == "cancelar": + result: dict = { + "decision": "cancelar", + "reasoning": reasoning, + "cancel_reason": cancel_reason, + } + else: + result = {"decision": "continuar", "reasoning": reasoning} + if duplicate_match is not None: + result["duplicate_match"] = duplicate_match + return result + + +def build_reclassification_decision( + decision: str, + reasoning: str | None, + new_modality: str | None = None, + new_motive: str | None = None, +) -> dict: + if decision == "reclassificar": + return { + "decision": "reclassificar", + "reasoning": reasoning, + "new_modality": new_modality, + "new_motive": new_motive, + } + return {"decision": "continuar", "reasoning": reasoning} + + +def build_forwarding_decision_do_not_forward( + reasoning: str, + target_operator: str | None = None, +) -> dict: + return { + "decision": "continuar", + "reasoning": reasoning, + "target_operator": target_operator, + } + + +def apply_cancellation_triplet(context: dict) -> dict: + context["siebel_action"] = "cancelar" + context["reason1"] = "Processo Interno" + context["reason2"] = "Atendimento" + context["reason3"] = "Cancelamento ID Anatel" + return context + + +def apply_treatment_triplet(context: dict) -> dict: + context["siebel_action"] = "tratamento" + context["reason1"] = "Processo Interno" + context["reason2"] = "Turbina" + context["reason3"] = "Internalização" + return context + + +def apply_reclassification_triplet(context: dict) -> dict: + context["siebel_action"] = "reclassificar" + context["reason1"] = "Processo Interno" + context["reason2"] = "Atendimento" + context["reason3"] = "Reclassificação ID" + return context diff --git a/src/utils/external_response_builder.py b/src/utils/external_response_builder.py new file mode 100644 index 0000000..1e3df5b --- /dev/null +++ b/src/utils/external_response_builder.py @@ -0,0 +1,53 @@ +"""Builds `processing.case_response` from `request_context`.""" +from typing import Optional + +from src.utils.decision_helpers import CANCEL_REASON_DUPLICATE + + +def build_canceling_external_response(context: dict) -> Optional[str]: + canceling_decision = context.get("canceling_decision") or {} + cancel_reason = canceling_decision.get("cancel_reason") or context.get("cancel_reason") + if not cancel_reason: + return None + # Duplicate uses the LLM-built external text (≤200 chars) to keep audit terminology out of case_response. + if cancel_reason == CANCEL_REASON_DUPLICATE: + case_text = context.get("canceling_case_response_text") + return f"{cancel_reason} - {case_text}" if case_text else cancel_reason + reasoning = ( + canceling_decision.get("reasoning") + or context.get("canceling_reasoning") + or context.get("model_reasoning") + or context.get("reasoning") + ) + return f"{cancel_reason} - {reasoning}" if reasoning else cancel_reason + + +def build_reclassification_external_response(context: dict) -> Optional[str]: + reclassification_decision = context.get("reclassification_decision") or {} + new_modality = reclassification_decision.get("new_modality") or context.get("new_modality") + new_motive = reclassification_decision.get("new_motive") or context.get("new_motive") + complaint = context.get("complaint") or {} + original_modality = complaint.get("modality") + original_motive = complaint.get("motive") + if not (new_modality and new_motive): + return None + return ( + f"O cliente solicita {new_modality} - {new_motive} " + f"e a classificação enviada foi {original_modality} - {original_motive}" + ) + + +def build_forwarding_external_response(context: dict) -> Optional[str]: + return context.get("forwarding_case_response_text") or None + + +def build_external_response(context: dict, decision: str) -> Optional[str]: + builders = { + "cancelar": build_canceling_external_response, + "reclassificar": build_reclassification_external_response, + "reencaminhar": build_forwarding_external_response, + "tratamento": None, + "continuar": None, + } + builder = builders.get(decision) + return builder(context) if builder else None diff --git a/src/utils/forwarding_helpers.py b/src/utils/forwarding_helpers.py new file mode 100644 index 0000000..37207b8 --- /dev/null +++ b/src/utils/forwarding_helpers.py @@ -0,0 +1,179 @@ +import logging + +from src.agent.state.agent_state import AgentState, update_state_metadata +from src.agent.state.steps import GraphStep + +logger = logging.getLogger(__name__) + +# Sentinel echoed by the LLM when a field is absent from the complaint description. +ABSENT_FROM_COMPLAINT = "ausente na reclamação" + + +def is_tim(name: str | None) -> bool: + return "TIM" in (name or "").upper() + + +# Short identifiers used in tracing/logs. +FORWARD_REASON_ABRT_OPERATOR = "abrt_operator_identified" +FORWARD_REASON_PORTABILITY_OPERATOR = "portability_operator_identified" +FORWARD_REASON_ABRT_ERROR = "abrt_error" +FORWARD_REASON_ABRT_INVALID = "abrt_invalid_response" +FORWARD_REASON_ABRT_NOT_FOUND = "abrt_not_found" +FORWARD_REASON_IMDB_ERROR = "imdb_error" +FORWARD_REASON_CONTEXT_OPERATOR = "context_operator" + +# Human-readable phrases published to CMS/Siebel. +DO_NOT_FORWARD_REASON_NO_MSISDN = ("Telefone reclamado (msisdn) não fornecido na reclamação para consulta na ABRT. " + "Como não é possível identificar a operadora via descrição, seguindo com o tratamento na TIM.") +DO_NOT_FORWARD_REASON_IMDB_MISSING = "Enriquecimento da base da TIM ausente. Seguindo com o tratamento na TIM." +DO_NOT_FORWARD_REASON_IMDB_TIM = "Telefone reclamado (msisdn) pertence à TIM segundo a base da TIM. Seguindo com o tratamento." +DO_NOT_FORWARD_REASON_ABRT_ERROR = "Falha ao consultar a ABRT. Seguindo com o tratamento na TIM por segurança." +DO_NOT_FORWARD_REASON_ABRT_NOT_FOUND = "Operadora não identificada na ABRT. Reclamação seguirá para tratamento na TIM." +DO_NOT_FORWARD_REASON_ABRT_INVALID = "Resposta da ABRT inconsistente (status ou operadora inválidos). Seguindo com o tratamento na TIM por segurança." +DO_NOT_FORWARD_REASON_ABRT_TIM = "Cliente identificado como TIM na ABRT. Reclamação seguirá para tratamento na TIM." +DO_NOT_FORWARD_REASON_PORTABILITY_TIM = "Cliente era TIM na data da reclamação em cenário de portout. Reclamação seguirá para tratamento na TIM." +DO_NOT_FORWARD_REASON_PORTABILITY_UNDETERMINED = "Não foi possível identificar o resultado da portabilidade ou dados foram inconclusivos. Seguir com o tratamento na TIM por segurança." +DO_NOT_FORWARD_REASON_DESCRIPTION_TIM = "Reclamação identificada como TIM via descrição." + + +# Per-reason narrative published as `forwarding_decision.forwarding_reason`. +_DO_NOT_FORWARD_DECISION_REASONING = { + DO_NOT_FORWARD_REASON_NO_MSISDN: ( + "A análise da descrição não permitiu identificar de forma conclusiva uma " + "operadora alvo, e o número reclamado (msisdn) não foi informado para " + "consulta na base TIM. Sem evidências suficientes para reencaminhar, a " + "reclamação seguirá com tratamento na TIM." + ), + DO_NOT_FORWARD_REASON_IMDB_MISSING: ( + "Não foi possível recuperar os dados do número reclamado na base da " + "TIM para validar a operadora atual. Sem evidências confiáveis para " + "reencaminhar, considerando também a análise da descrição, a " + "reclamação seguirá com tratamento na TIM." + ), + DO_NOT_FORWARD_REASON_IMDB_TIM: ( + "A consulta à base da TIM indicou que o número reclamado possui " + "contrato ativo na TIM. Pela regra, vínculo ativo na base TIM " + "direciona a reclamação para tratamento na TIM." + ), + DO_NOT_FORWARD_REASON_ABRT_ERROR: ( + "A consulta ao site da ABR Telecom falhou e não foi possível obter " + "informação confiável sobre a operadora atual do número reclamado. Por " + "segurança, e na ausência de evidências conclusivas para reencaminhar, " + "a reclamação seguirá com tratamento na TIM." + ), + DO_NOT_FORWARD_REASON_ABRT_NOT_FOUND: ( + "A consulta ao site da ABR Telecom não localizou o número reclamado e " + "a análise da descrição não identificou de forma conclusiva outra " + "operadora responsável pelo problema. Sem evidências para reencaminhar, " + "a reclamação seguirá com tratamento na TIM." + ), + DO_NOT_FORWARD_REASON_ABRT_INVALID: ( + "A resposta da ABR Telecom apresentou status ou operadora inválidos, " + "não permitindo identificar de forma confiável a operadora atual do " + "número reclamado. Por segurança, a reclamação seguirá com tratamento " + "na TIM." + ), + DO_NOT_FORWARD_REASON_ABRT_TIM: ( + "A consulta ao site da ABR Telecom identificou o número reclamado " + "como pertencente à TIM. Pela regra, atribuição à TIM na ABRT " + "direciona a reclamação para tratamento na TIM." + ), + DO_NOT_FORWARD_REASON_PORTABILITY_TIM: ( + "A análise de portabilidade indicou que, na data de abertura da " + "reclamação, o número estava vinculado à TIM (cenário de portout). " + "Pela regra, vínculo TIM na data da reclamação direciona o caso " + "para tratamento na TIM." + ), + DO_NOT_FORWARD_REASON_PORTABILITY_UNDETERMINED: ( + "A análise de portabilidade não permitiu determinar com clareza qual " + "operadora detinha o número na data da reclamação. Por segurança, e na " + "ausência de evidências conclusivas, a reclamação seguirá com " + "tratamento na TIM." + ), + DO_NOT_FORWARD_REASON_DESCRIPTION_TIM: ( + "A análise da descrição identificou que o problema reclamado é " + "referente à TIM, indicando claramente que a TIM é a operadora " + "responsável. Portanto, a reclamação seguirá com tratamento na TIM." + ), +} + + +def do_not_forward(state: AgentState, context: dict, reason: str, target_operator: str = "Não identificada") -> AgentState: + logger.info(f"forward_complaint=False | Reason: {reason}") + context["forward_complaint"] = False + decision_reasoning = _DO_NOT_FORWARD_DECISION_REASONING.get(reason, reason) + context["forwarding_decision"] = { + "decision": "continuar", + "complaint_description_analysis": context.get("model_reasoning"), + "target_operator": target_operator, + "forwarding_reason": decision_reasoning, + } + return update_state_metadata(state, request_context=context) + + +def forward_complaint( + state: AgentState, + context: dict, + reason: str, + operator: str | None, +) -> AgentState: + logger.info(f"forward_complaint=True | Reason: {reason} | Operator: {operator!r}") + op_name = operator or "Não identificada" + context["forward_complaint"] = True + context["forward_reason"] = reason + context["forward_operator"] = op_name + context["siebel_action"] = "reencaminhar" + context["reason1"] = "Processo Interno" + context["reason2"] = "Atendimento" + context["reason3"] = "ID Outra Operadora" + protocol = (context.get("complaint") or {}).get("complaintProtocol") or ABSENT_FROM_COMPLAINT + summary = context.get("complaint_summary") or ABSENT_FROM_COMPLAINT + quote = context.get("complaint_quote") or ABSENT_FROM_COMPLAINT + + if reason == FORWARD_REASON_CONTEXT_OPERATOR: + external_response_msg = ( + f"Solicito o Reencaminhamento da solicitação nº {protocol} para a " + f"operadora {op_name}, pois consumidor reclama de {summary}, " + f"conforme se verifica no trecho \"{quote}\"." + ) + forwarding_reason_msg = ( + f"A análise da descrição identificou que o problema reclamado é referente " + f"à prestadora {op_name}. Como o número reclamado (msisdn) não foi " + f"informado, não é possível validar a operadora atual via ABR Telecom; a " + f"decisão de reencaminhar baseia-se exclusivamente na operadora " + f"identificada na descrição." + ) + elif reason == FORWARD_REASON_ABRT_NOT_FOUND: + external_response_msg = ( + f"Solicito o Reencaminhamento da solicitação nº {protocol} para a " + f"operadora {op_name}, pois consumidor reclama de {summary}, " + f"conforme se verifica no trecho \"{quote}\"." + ) + forwarding_reason_msg = ( + f"A consulta ao site da ABR Telecom não localizou o número reclamado, " + f"indicando ausência de vínculo ativo com a TIM. A análise da descrição " + f"apontou {op_name} como operadora responsável pelo problema, portanto o " + f"reencaminhamento foi decidido a esta operadora." + ) + else: + external_response_msg = ( + f"Conforme o site da ABR Telecom, o número reclamado pertence à prestadora " + f"{op_name} e, em nenhum momento esteve na base TIM, portanto solicito o " + f"reencaminhamento desta ID" + ) + forwarding_reason_msg = ( + f"A consulta ao site da ABR Telecom indicou que o número reclamado " + f"pertence à prestadora {op_name} e nunca esteve vinculado à TIM. A " + f"análise da descrição é consistente com este resultado, portanto o " + f"reencaminhamento foi decidido a esta operadora." + ) + + context["forwarding_decision"] = { + "decision": "reencaminhar", + "complaint_description_analysis": context.get("model_reasoning"), + "target_operator": op_name, + "forwarding_reason": forwarding_reason_msg, + } + context["forwarding_case_response_text"] = external_response_msg + state["current_step"] = GraphStep.FORWARDING_ANALYSIS_COMPLETED + return update_state_metadata(state, request_context=context) diff --git a/src/utils/http.py b/src/utils/http.py new file mode 100644 index 0000000..825bc14 --- /dev/null +++ b/src/utils/http.py @@ -0,0 +1,177 @@ +"""Factory de clientes HTTP com tracing automático para Langfuse. + +Use `traced_async_client` / `traced_sync_client` no lugar de +`httpx.AsyncClient` / `httpx.Client` em todo client de API externa. +A captura de endpoint, status code e response body é feita +transparentemente — o dev não precisa chamar `set_tool_call_metadata`. + +Ver `docs/api-clients.md` para o padrão completo. +""" + +from __future__ import annotations + +import time +from typing import Any, Callable, Optional + +import httpx + +from src.core.config import settings +from src.utils.observer import set_tool_call_metadata + +ResponseSanitizer = Callable[[Any], Any] + +_MAX_TEXT_BODY = 2000 + + +def _extract_body(response: httpx.Response) -> Any: + try: + return response.json() + except Exception: + text = response.text or "" + return text[:_MAX_TEXT_BODY] if text else None + + +def _extract_request_body(request: httpx.Request) -> Any: + """Best-effort decode of the outgoing request body. + + Tries JSON first (since most internal APIs are JSON), then falls back to + a truncated text representation. Returns None for empty/missing bodies. + """ + try: + raw = request.content + except Exception: + return None + if not raw: + return None + try: + import json as _json + return _json.loads(raw) + except Exception: + try: + text = raw.decode("utf-8", errors="replace") + except Exception: + return None + return text[:_MAX_TEXT_BODY] if text else None + + +def _request_timeout(request: httpx.Request, fallback: Any) -> Any: + """Best-effort retrieval of the effective per-request timeout.""" + try: + return request.extensions.get("timeout", fallback) + except Exception: + return fallback + + +def _record( + response: httpx.Response, + sanitizer: Optional[ResponseSanitizer], + elapsed_ms: float, + client_timeout: Any, +) -> None: + body = _extract_body(response) + if sanitizer is not None: + try: + body = sanitizer(body) + except Exception: + pass + set_tool_call_metadata( + endpoint=str(response.request.url), + status_code=response.status_code, + response_body=body, + method=response.request.method, + request_headers=response.request.headers, + request_body=_extract_request_body(response.request), + response_headers=response.headers, + timeout=_request_timeout(response.request, client_timeout), + latency_ms=round(elapsed_ms, 3), + http_version=response.http_version, + ) + + +def _record_transport_error( + request: httpx.Request, + exc: BaseException, + elapsed_ms: float, + client_timeout: Any, +) -> None: + set_tool_call_metadata( + endpoint=str(request.url), + status_code=None, + response_body=str(exc) or repr(exc), + method=request.method, + request_headers=request.headers, + request_body=_extract_request_body(request), + response_headers=None, + timeout=_request_timeout(request, client_timeout), + latency_ms=round(elapsed_ms, 3), + http_version=None, + ) + + +class _TracedAsyncClient(httpx.AsyncClient): + def __init__(self, *, response_sanitizer: Optional[ResponseSanitizer] = None, **kwargs: Any) -> None: + self._sanitizer = response_sanitizer + self._configured_timeout = kwargs.get("timeout") + super().__init__(**kwargs) + + async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response: + started_at = time.perf_counter() + try: + response = await super().send(request, **kwargs) + except Exception as exc: + elapsed_ms = (time.perf_counter() - started_at) * 1000.0 + _record_transport_error(request, exc, elapsed_ms, self._configured_timeout) + raise + elapsed_ms = (time.perf_counter() - started_at) * 1000.0 + _record(response, self._sanitizer, elapsed_ms, self._configured_timeout) + return response + + +class _TracedClient(httpx.Client): + def __init__(self, *, response_sanitizer: Optional[ResponseSanitizer] = None, **kwargs: Any) -> None: + self._sanitizer = response_sanitizer + self._configured_timeout = kwargs.get("timeout") + super().__init__(**kwargs) + + def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response: + started_at = time.perf_counter() + try: + response = super().send(request, **kwargs) + except Exception as exc: + elapsed_ms = (time.perf_counter() - started_at) * 1000.0 + _record_transport_error(request, exc, elapsed_ms, self._configured_timeout) + raise + elapsed_ms = (time.perf_counter() - started_at) * 1000.0 + _record(response, self._sanitizer, elapsed_ms, self._configured_timeout) + return response + + +def traced_async_client( + *, + response_sanitizer: Optional[ResponseSanitizer] = None, + verify_ssl: Any = None, + **kwargs: Any, +) -> httpx.AsyncClient: + """Retorna um httpx.AsyncClient que registra metadata de cada request. + + Args: + response_sanitizer: Filtro opcional aplicado ao body antes do registro + (ex: remover access_token de respostas de OAuth). + verify: Verificação SSL. Default: settings.VERIFY_SSL. + **kwargs: Repassados para httpx.AsyncClient (timeout, headers, base_url...). + """ + if verify_ssl is None: + verify_ssl = settings.VERIFY_SSL + return _TracedAsyncClient(response_sanitizer=response_sanitizer, verify=verify_ssl, **kwargs) + + +def traced_sync_client( + *, + response_sanitizer: Optional[ResponseSanitizer] = None, + verify: Any = None, + **kwargs: Any, +) -> httpx.Client: + """Versão síncrona de `traced_async_client`.""" + if verify is None: + verify = settings.VERIFY_SSL + return _TracedClient(response_sanitizer=response_sanitizer, verify=verify, **kwargs) diff --git a/src/utils/ics_collector.py b/src/utils/ics_collector.py new file mode 100644 index 0000000..ccb5394 --- /dev/null +++ b/src/utils/ics_collector.py @@ -0,0 +1,675 @@ +""" +ICS Collector - Captura eventos do Observer durante execução. + +Coleta ICs (Itens de Controle) emitidos via agent_framework.observer.event +durante a execução do grafo sem afetar o código existente. + +Além da coleta de ICs, cria observations reais no Langfuse SDK para: +- event() → span pontual com o código do IC (CAN.001, AGA.005, etc.) + +CRITICAL: This code is designed to NEVER break the agent, even on errors: +- All methods have try/except to swallow exceptions +- Returns safe defaults (empty lists) on any failure +- Thread-local storage prevents request interference +- Monkey patch is completely isolated from agent execution +""" + +from typing import List, Dict, Any, Optional +from threading import Lock +from datetime import datetime, timezone +import logging + +# Suppressed logger - never throws, just silent +logger = logging.getLogger(__name__) + + +PLACEHOLDER = "N/A" + +# IC tags that must carry {"noc": True} in the event metadata. +NOC_TAGS = frozenset({ + "AGA.001", "AGA.002", "AGA.003", "AGA.006", "AGA.008", "AGA.009", + "AGA.010", "AGA.011", "AGA.012", "AGA.019", "AGA.020", "AGA.022", "AGA.023", + "NOC.001", "NOC.002", "NOC.003", "NOC.004", "NOC.005", "NOC.006", + "NOC.007", "NOC.008", "NOC.009", +}) + +# Per-IC informational fields contract. Missing values are filled with PLACEHOLDER. +# Tags not listed here carry only the "Todos os Ics" common fields. +IC_INFO_FIELDS: Dict[str, List[str]] = { + "AGA.001": [ + "agentSpecificData.caseCreatedAt", + "agentSpecificData.anatelIdCollectedAt", + "agentSpecificData.anatelId", + "agentSpecificData.customerDocument", + "sessionCreatedAt", + "agentSpecificData.anatelMotive", + ], + "AGA.002": ["agentSpecificData.statusCode", "intention", "tokenInput", "tokenOutput", "latencyMs"], + "AGA.003": ["agentSpecificData.statusCode", "intention", "tokenInput", "tokenOutput", "latencyMs"], + "AGA.004": ["agentSpecificData.statusCode", "intention", "tokenInput", "tokenOutput", "latencyMs"], + "AGA.005": ["agentSpecificData.statusCode", "intention", "tokenInput", "tokenOutput", "latencyMs"], + "AGA.006": [ + "agentSpecificData.protocolCreatorLogin", + "apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs", + ], + "AGA.007": ["apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs"], + "AGA.008": ["apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs"], + "AGA.009": [ + "agentSpecificData.caseCreatedAt", + "agentSpecificData.anatelIdCollectedAt", + "agentSpecificData.anatelId", + "agentSpecificData.customerDocument", + "sessionCreatedAt", + "agentSpecificData.anatelMotive", + ], + "AGA.010": ["apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs"], + "AGA.011": ["apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs"], + "AGA.012": [ + "apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs", + "ragRetrievedDocuments", "ragSelectedDocuments", "noMatchRag", + ], + "AGA.013": ["apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs"], + "AGA.014": ["tokenInput", "tokenOutput", "latencyMs"], + "AGA.016": [ + "agentSpecificData.protocolCreatorLogin", + "apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs", + ], + "AGA.018": [ + "agentSpecificData.validatedFields", + "agentSpecificData.validatedFieldsCount", + ], + "AGA.019": ["apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs"], + "AGA.020": [ + "apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs", + "ragRetrievedDocuments", "ragSelectedDocuments", "noMatchRag", + ], + "AGA.021": ["ragRetrievedDocuments", "ragSelectedDocuments", "noMatchRag"], + "AGA.024": [ + "llmResponse", "tokenInput", "tokenOutput", "latencyMs", + "customerMessage", "blockingGuardrailsInput", "blockingGuardrailsOutput", + "ragRetrievedDocuments", "ragSelectedDocuments", "noMatchRag", + ], + "AGA.025": [ + "agentSpecificData.attemptNumber", + "llmResponse", + "tokenInput", "tokenOutput", "latencyMs", + "customerMessage", + ], + "AGA.026": [], + "AGA.027": [ + "agentSpecificData.missingFields", + "agentSpecificData.missingFieldsCount", + "agentSpecificData.validationErrors", + ], + "AGA.028": ["apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs"], + "AGA.029": [ + "agentSpecificData.id", + "agentSpecificData.complaintProtocol", + "agentSpecificData.priorTransactionId", + "agentSpecificData.bypassActive", + ], + "AGA.030": [ + "agentSpecificData.complaintProtocol", + "resourceName", + "errorType", + "errorMessage", + "latencyMs", + ], + "AGA.031": ["agentSpecificData.statusCode", "intention", "tokenInput", "tokenOutput", "latencyMs"], + "AGA.032": ["agentSpecificData.statusCode", "intention"], + "AGA.033": ["agentSpecificData.statusCode", "intention", "tokenInput", "tokenOutput", "latencyMs"], + "AGA.034": ["apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs"], + "AGA.035": ["apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs"], + "AGA.036": ["apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs"], + "AGA.037": ["apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs"], + "AGA.038": ["apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs"], + "AGA.039": ["apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs"], + "AGA.040": ["apiUrl", "apiStatusCode", "apiResponsePayload", "latencyMs"], + # NOC ICs — no informational fields beyond the NOC common fields + "NOC.001": [], + "NOC.002": [], + "NOC.003": [], + "NOC.004": [], + "NOC.005": [], + "NOC.006": [], + "NOC.007": [], + "NOC.009": [], +} + + +def _get_langfuse_client(): + """Retorna o Langfuse client se disponível, senão None.""" + try: + from langfuse import get_client + return get_client() + except Exception: + return None + + +def to_millis(value: Any) -> Optional[int]: + """Convert a datetime, ISO 8601 string, or numeric value to Unix epoch milliseconds. + + Returns None when the value is missing or cannot be parsed, so callers can + fall back to PLACEHOLDER via build_ic_info_fields. Naive datetimes are + assumed UTC. Numeric inputs >= 1e12 are treated as already in milliseconds. + """ + if value is None: + return None + if isinstance(value, datetime): + dt = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return int(value if value >= 1_000_000_000_000 else value * 1000) + if isinstance(value, str): + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) + except (ValueError, TypeError): + return None + return None + + +def now_millis() -> int: + return int(datetime.now(timezone.utc).timestamp() * 1000) + + +def build_anatel_entry_fields(state: Optional[dict]) -> Dict[str, Any]: + """Build the 6 Anatel ticket identifier fields shared by AGA.001 (agent entry) + and AGA.009 (agent failure). + + Extracts complaint and customer data from state.metadata.request_context. + Missing fields fall back to None; build_ic_payload then fills with PLACEHOLDER. + """ + s = state or {} + metadata = s.get("metadata", {}) or {} + request_context = metadata.get("request_context", {}) or {} + complaint = request_context.get("complaint", {}) or {} + customer = request_context.get("customer", {}) or {} + return { + "agentSpecificData.caseCreatedAt": to_millis(complaint.get("openedAt")), + "agentSpecificData.anatelIdCollectedAt": to_millis(complaint.get("dueAt")), + "agentSpecificData.anatelId": complaint.get("complaintProtocol"), + "agentSpecificData.customerDocument": customer.get("cpfCnpj"), + "sessionCreatedAt": now_millis(), + "agentSpecificData.anatelMotive": complaint.get("motive"), + } + + +def build_llm_token_fields(llm_resp: Optional[Any] = None) -> Dict[str, Any]: + """Build tokenInput/tokenOutput/latencyMs fields for AGAs that wrap an + LLM call (AGA.002, AGA.003, AGA.004, AGA.005, AGA.014, AGA.031, AGA.033). + + Accepts either: + - An LlmResponse-like object (with `prompt_tokens`, `completion_tokens`, + `latency_ms` attributes), as returned by `chat_llm_with_usage`. + - A dict (typically the `llm_meta` propagated by analysis helpers) with + the same keys: `prompt_tokens`, `completion_tokens`, `latency_ms`. + + When `None` (e.g., LLM call failed before producing a response), all + fields fall back to None and build_ic_payload fills with PLACEHOLDER. + """ + if llm_resp is None: + return { + "tokenInput": None, + "tokenOutput": None, + "latencyMs": None, + } + + if isinstance(llm_resp, dict): + prompt_tokens = llm_resp.get("prompt_tokens") + completion_tokens = llm_resp.get("completion_tokens") + latency_ms = llm_resp.get("latency_ms", 0) + else: + prompt_tokens = getattr(llm_resp, "prompt_tokens", None) + completion_tokens = getattr(llm_resp, "completion_tokens", None) + latency_ms = getattr(llm_resp, "latency_ms", 0) + + return { + "tokenInput": prompt_tokens, + "tokenOutput": completion_tokens, + "latencyMs": int(latency_ms or 0), + } + + +def build_rag_telemetry_fields( + retrieved: Optional[List[Dict[str, Any]]] = None, + selected: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """Build the 3 RAG telemetry fields (ragRetrievedDocuments, + ragSelectedDocuments, noMatchRag) shared by AGA.012, AGA.020, AGA.021 + and AGA.024. + + - `retrieved`: ALL candidates the RAG fetched (capped to telemetry_top_n + upstream — typically up to ~20 from TAIS DB). Reflects what the search + surfaced as relevant given the query embedding + filters. + - `selected`: the subset the agent actually used (top_k after dedup — + typically 3). When `selected` is None, `retrieved` is reused for both + (for callers that don't distinguish the two yet). + + Heavy `chunk`/`content` payloads are stripped — only documentId/title/ + distance are kept for observability. + + `noMatchRag` is True when `retrieved` is empty (RAG itself found nothing + above the distance threshold). If retrieved has items but the agent + selected zero, noMatchRag stays False — the RAG did its job, the agent + just chose not to use the results. + """ + def _slim(docs: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]: + return [ + { + "documentId": d.get("documentId") or d.get("id_proc"), + "title": d.get("title") or d.get("title_proc"), + "distance": d.get("distance"), + } + for d in (docs or []) + ] + + retrieved_slim = _slim(retrieved) + selected_slim = _slim(selected) if selected is not None else retrieved_slim + return { + "ragRetrievedDocuments": retrieved_slim, + "ragSelectedDocuments": selected_slim, + "noMatchRag": len(retrieved_slim) == 0, + } + + +def build_llm_payload_fields( + llm_response: Optional[str] = None, + prompt: Optional[str] = None, + no_match_rag: Optional[bool] = None, + alucination_score: Optional[float] = None, +) -> Dict[str, Any]: + """Standard payload fields for ICs emitted with origin='LLM'. + + Returns a dict with llmResponse, alucinationScore, noMatchRag and + promptLength so every LLM-origin IC carries the same contract. + Values default to None when not available (e.g., LLM call failed + before producing a response, or RAG not involved in the flow). + """ + return { + "llmResponse": llm_response, + "alucinationScore": alucination_score, + "noMatchRag": no_match_rag, + "promptLength": len(prompt) if prompt is not None else None, + } + + +def build_common_fields(state: Optional[dict], tag: str) -> Dict[str, Any]: + """Build common fields shared by every IC and NOC event.""" + from src.core.config import settings as _settings + s = state or {} + metadata = s.get("metadata", {}) or {} + request_context = metadata.get("request_context", {}) or {} + customer = request_context.get("customer", {}) or {} + complaint = request_context.get("complaint", {}) or {} + origin = request_context.get("origin", {})or {} + transaction_id = ( + metadata.get("transaction_id") + or metadata.get("transactionId") + or request_context.get("transactionId") + or "N/A" + ) + return { + "sessionId": s.get("session_id") or PLACEHOLDER, + "messageId": transaction_id or PLACEHOLDER, + "gsm": customer.get("msisdn") or PLACEHOLDER, + "tag": tag, + "agentId": "backoffice", + "channelId": origin.get("sourceSystem") or PLACEHOLDER, + "eventDate": now_millis(), + } + + +def build_ic_info_fields(tag: str, provided: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Build per-IC informational fields, filling missing keys with PLACEHOLDER. + + Tags absent from IC_INFO_FIELDS carry no extra fields. + Provided keys outside the schema are ignored. + """ + schema = IC_INFO_FIELDS.get(tag, []) + provided = provided or {} + return {field: provided.get(field, PLACEHOLDER) for field in schema} + + +def build_ic_payload( + state: Optional[dict], + tag: str, + provided: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Build the full IC payload: common fields + per-IC fields. + + event("AGA.001", { + **build_ic_payload(state, "AGA.001", {"agentSpecificData.anatelId": ...}), + }) + + Missing per-IC keys default to PLACEHOLDER ('TBD'). + """ + return { + **build_common_fields(state, tag), + **build_ic_info_fields(tag, provided), + } + + +def build_noc_metadata(state: Optional[dict], tag: str) -> Dict[str, Any]: + return build_ic_payload(state, tag) + + +def build_noc_llm_metadata( + state: Optional[dict], + tag: str, + *, + latency_ms: int, + llm_endpoint: str, + model_name: str, +) -> Dict[str, Any]: + return { + **build_ic_payload(state, tag), + "latencyMs": latency_ms, + "llmEndpoint": llm_endpoint, + "modelName": model_name, + } + + +def build_noc_db_metadata( + state: Optional[dict], + tag: str, + *, + latency_ms: int, + resource_name: str, +) -> Dict[str, Any]: + return { + **build_ic_payload(state, tag), + "latencyMs": latency_ms, + "resourceName": resource_name, + } + + +def build_noc_api_metadata( + state: Optional[dict], + tag: str, + *, + retry_count: int, + latency_ms: int, + api_url: str, + status_code: int, +) -> Dict[str, Any]: + return { + **build_ic_payload(state, tag), + "retryCount": retry_count, + "latencyMs": latency_ms, + "apiUrl": api_url, + "statusCode": status_code, + } + + +def build_noc_latency_metadata( + state: Optional[dict], + tag: str, + *, + latency_ms: int, +) -> Dict[str, Any]: + return { + **build_ic_payload(state, tag), + "latencyMs": latency_ms, + } + + +# Backward-compatible aliases (bypass_rules_node, cache_check_node) +build_noc_payload = build_noc_metadata +build_noc_db_payload = build_noc_db_metadata +build_noc_api_payload = build_noc_api_metadata +build_noc_llm_payload = build_noc_llm_metadata + + +def _format_event(payload: dict) -> dict: + return { + "id": payload.get("call_id") or payload.get("session_id"), + "status": payload.get("status"), + "timestamp": datetime.now(timezone.utc).isoformat(), + "origin": payload.get("origin"), + "payload": { + k: v + for k, v in payload.items() + if k not in {"call_id", "session_id", "status", "origin", "noc"} + } + } + +def update_current_span(data: dict) -> None: + """Atualiza o span ativo do Langfuse sem expor o SDK nos nodes.""" + try: + lf = _get_langfuse_client() + if lf: + obs = lf.get_current_observation() + if obs: + obs.update(metadata=data) + except Exception: + pass + + +class ICsCollector: + """Coleta ICs emitidos durante execução sem interferir no fluxo.""" + + _sessions: Dict[str, List[Dict[str, Any]]] = {} + _lock = Lock() + _enabled = True # Feature flag para desabilitar totalmente se necessário + + @classmethod + def set_enabled(cls, enabled: bool) -> None: + """Habilita/desabilita coleta (útil para fallback em produção).""" + cls._enabled = enabled + + @classmethod + def start(cls, session_id: Optional[str] = None) -> None: + """Inicia coleta de ICs para esta requisição.""" + try: + if not cls._enabled: + return + key = session_id or "__default__" + with cls._lock: + cls._sessions[key] = [] + except Exception: + pass + + @classmethod + def stop(cls, session_id: Optional[str] = None) -> List[Dict[str, Any]]: + """Para coleta e retorna ICs capturados. NUNCA LANÇA EXCEÇÃO.""" + try: + if not cls._enabled: + return [] + key = session_id or "__default__" + with cls._lock: + ics = cls._sessions.pop(key, []) + return ics if isinstance(ics, list) else [] + except Exception: + return [] + + @classmethod + def add(cls, code: str, ic_type: str, description: str, metadata: Optional[Dict] = None) -> None: + """Adiciona IC à coleção se coletor estiver ativo. NUNCA LANÇA EXCEÇÃO.""" + try: + if not cls._enabled: + return + payload = metadata or {} + session_id = payload.get("sessionId") or payload.get("session_id") or "__default__" + + ic = { + "code": code, + "type": ic_type, + "description": description, + "metadata": payload, + "timestamp": datetime.utcnow().isoformat() + "Z" + } + with cls._lock: + if session_id not in cls._sessions: + return + cls._sessions[session_id].append(ic) + except Exception: + # Silently fail, never break agent + pass + + @classmethod + def get_current(cls, session_id: Optional[str] = None) -> List[Dict[str, Any]]: + """Retorna ICs coletados até agora sem parar a coleta. NUNCA LANÇA EXCEÇÃO.""" + try: + if not cls._enabled: + return [] + key = session_id or "__default__" + with cls._lock: + ics = cls._sessions.get(key, []) + return ics.copy() if isinstance(ics, list) else [] + except Exception: + return [] + + +def _emit_langfuse_event( + code: str, + ic_type: str, + description: str, + payload: dict, + event_metadata: Optional[Dict[str, Any]] = None, +) -> None: + """Cria um event observation pontual no Langfuse como filho do span ativo. + + Cada event() do framework (CAN.001, AGA.005, etc.) se torna um span + visível na timeline do Langfuse, permitindo acompanhar passo a passo. + """ + try: + lf = _get_langfuse_client() + if not lf: + return + + session_id = ( + payload.get("sessionId") + or payload.get("session_id") + or (event_metadata or {}).get("sessionId") + or "" + ) + langfuse_meta: Dict[str, Any] = { + "tag": payload.get("tag", code), + "type": ic_type, + "step": payload.get("step", ""), + "session_id": session_id, + } + if event_metadata: + for k, v in event_metadata.items(): + if k not in langfuse_meta: + langfuse_meta[k] = v + with lf.start_as_current_observation( + as_type="span", + name=code, + input=_format_event(payload), + metadata=langfuse_meta, + end_on_exit=True, + ): + pass # Span pontual — abre e fecha imediatamente + except Exception: + pass + + +def _patch_noc_otel() -> None: + """Patch _build_noc_payload to read NOC fields from event.data too. + + The framework's _build_noc_payload only reads from event.metadata, but our + events spread the NOC fields into event.data (via **build_noc_metadata in + the data dict). This patch merges both so routing works regardless of + where the fields land. + """ + try: + from agent_framework.observability import otel as _otel + if getattr(_otel._build_noc_payload, '_ics_patched', False): + return + _original = _otel._build_noc_payload + + def _patched(event): + from datetime import datetime, timezone + import time as _time + metadata = getattr(event, "metadata", None) or {} + data = getattr(event, "data", None) or {} + src = {**data, **metadata} + ts = getattr(event, "timestamp", None) + if isinstance(ts, datetime): + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + event_date = int(ts.timestamp() * 1000) + else: + event_date = int(_time.time() * 1000) + payload = { + "uraCallId": src.get("uraCallId"), + "sessionId": src.get("sessionId"), + "messageId": src.get("messageId"), + "transcriptionId": src.get("transcriptionId"), + "gsm": src.get("gsm"), + "ani": src.get("ani"), + "tag": src.get("tag"), + "sessionCreatedAt": src.get("sessionCreatedAt"), + "agentId": src.get("agentId"), + "channelId": src.get("channelId"), + "eventDate": event_date, + "customerMessage": src.get("customerMessage"), + "customerId": src.get("customerId"), + } + return {k: v for k, v in payload.items() if v is not None} + + _patched._ics_patched = True + _otel._build_noc_payload = _patched + except Exception: + pass + + + +def monkey_patch_observer(): + """ + Patch only agent_framework.observer.event to collect legacy AGA/NOC/IC events. + + Option B architecture: LLM generation telemetry is not implemented or + monkey-patched in the backend. All LLM generation, token and cost telemetry + is emitted by agent_framework.llm.providers. + """ + _patch_noc_otel() + try: + from agent_framework import observer + + event_patched = getattr(observer.event, '_ics_collector_patched', False) + if event_patched: + return + + original_event = getattr(observer.event, '_original_event', observer.event) + + def wrapped_event(*args, **kwargs): + code = "UNKNOWN" + payload = {} + ic_type = "INFO" + description = "" + try: + code = kwargs.get("name") or kwargs.get("code") + payload = kwargs.get("data") + if code is None and len(args) > 0: + code = args[0] + if payload is None and len(args) > 1 and isinstance(args[1], dict): + payload = args[1] + code = str(code) if code is not None else "UNKNOWN" + payload = payload or {} + ic_type = payload.get("type", "INFO") + description = payload.get("description", code) + ICsCollector.add(code, ic_type, description, payload) + except Exception: + pass + + try: + event_metadata = kwargs.get("metadata") or {} + _emit_langfuse_event(code, ic_type, description, payload, event_metadata) + except Exception: + pass + + return original_event(*args, **kwargs) + + wrapped_event._ics_collector_patched = True + wrapped_event._original_event = original_event + observer.event = wrapped_event + except Exception: + # Patch failed completely - this is ok, agent still works without ICS collection + pass diff --git a/src/utils/loaders.py b/src/utils/loaders.py new file mode 100644 index 0000000..b4c2922 --- /dev/null +++ b/src/utils/loaders.py @@ -0,0 +1,11 @@ +import json +from pathlib import Path + +def load_mock_json(file_name: str) -> dict: + """Carrega um JSON da pasta de mocks em src/utils/mocks/""" + # Pega o caminho absoluto da pasta onde este arquivo (loaders.py) está + # e navega até a subpasta mocks + path = Path(__file__).parent / "mocks" / file_name + + with open(path, "r", encoding="utf-8") as f: + return json.load(f) \ No newline at end of file diff --git a/src/utils/observer.py b/src/utils/observer.py new file mode 100644 index 0000000..7acb2a9 --- /dev/null +++ b/src/utils/observer.py @@ -0,0 +1,643 @@ +"""Configuração do Observer SDK e tracing Langfuse via SDK. + +Arquitetura dual: +- Observer (agent_framework) coleta ICs independentemente via monkey_patch_observer() +- Langfuse SDK com @observe instrumenta nós automaticamente via OTEL +- ICs coletados são anexados como metadata ao span ativo via set_span_attribute + +O decorator @trace_node envolve nós com @observe do SDK e anexa ICs como metadata. +""" + +from __future__ import annotations + +import asyncio +import os +from contextvars import ContextVar +from functools import wraps +from typing import Any, Callable, Optional + +from src.compat.framework_observer import configure + +from src.utils.ics_collector import ICsCollector, monkey_patch_observer +from src.core.logging import get_logger, log_operation + +logger = get_logger(__name__) + +# ContextVar preenchido pelos clients HTTP antes de retornar. +# trace_tool lê esse valor para enriquecer o output da observation. +_tool_call_metadata: ContextVar[dict] = ContextVar("_tool_call_metadata", default={}) + + +_SENSITIVE_HEADER_NAMES = frozenset({ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "api-key", + "apikey", +}) + + +def _mask_sensitive_value(value: str, prefix: int = 8, suffix: int = 4) -> str: + """Mascara parcialmente um valor sensível, preservando início e fim. + + Para headers Authorization no formato "<scheme> <token>" (ex: "Bearer xyz"), + o esquema é preservado e apenas o token é mascarado parcialmente. + """ + if not value: + return "***" + scheme, separator, token = value.partition(" ") + if separator and token: + if len(token) <= prefix + suffix: + return f"{scheme} ***" + return f"{scheme} {token[:prefix]}...{token[-suffix:]}" + if len(value) <= prefix + suffix: + return "***" + return f"{value[:prefix]}...{value[-suffix:]}" + + +def _redact_headers(headers: Any) -> Optional[dict]: + """Return a dict copy of headers with sensitive values partially masked. + + Accepts httpx.Headers, dict, or any iterable of (key, value) pairs. + Returns None when no headers are provided. + """ + if headers is None: + return None + try: + items = headers.items() if hasattr(headers, "items") else list(headers) + except Exception: + return None + redacted: dict[str, str] = {} + for raw_key, raw_value in items: + key_str = str(raw_key) + if key_str.lower() in _SENSITIVE_HEADER_NAMES: + redacted[key_str] = _mask_sensitive_value(str(raw_value)) + else: + redacted[key_str] = str(raw_value) + return redacted + + +def set_tool_call_metadata( + endpoint: str, + status_code: Optional[int], + response_body: Any = None, + *, + method: Optional[str] = None, + request_headers: Any = None, + request_body: Any = None, + response_headers: Any = None, + timeout: Any = None, + latency_ms: Optional[float] = None, + http_version: Optional[str] = None, +) -> None: + """Registra metadados da chamada HTTP para o trace_tool capturar. + + Deve ser chamado pelo client HTTP antes de retornar o resultado, + dentro do método decorado com @trace_tool. Os campos extras (method, + headers, timeout, latency) são opcionais para manter compatibilidade + com chamadas legadas, mas devem ser preenchidos pelos clients http + para enriquecer o trace no Langfuse. + + Args: + endpoint: URL completa consultada. + status_code: HTTP status code recebido (None em caso de erro de transporte). + response_body: Corpo da resposta já deserializado (dict/list). + method: Método HTTP (GET, POST, ...). + request_headers: Headers enviados (httpx.Headers ou dict). Segredos são redigidos. + response_headers: Headers recebidos. Segredos são redigidos. + timeout: Timeout efetivo do request (qualquer tipo serializável). + latency_ms: Latência medida em milissegundos. + http_version: Versão HTTP da resposta (ex: "HTTP/1.1"). + """ + _tool_call_metadata.set({ + "endpoint": endpoint, + "status_code": status_code, + "response_body": response_body, + "method": method, + "request_headers": _redact_headers(request_headers), + "request_body": request_body, + "response_headers": _redact_headers(response_headers), + "timeout": _serialize_timeout(timeout), + "latency_ms": latency_ms, + "http_version": http_version, + }) + + +def _serialize_timeout(timeout: Any) -> Any: + """Best-effort serialization for httpx.Timeout or scalar timeouts.""" + if timeout is None: + return None + if isinstance(timeout, (int, float, str, bool)): + return timeout + parts = {} + for attr in ("connect", "read", "write", "pool"): + value = getattr(timeout, attr, None) + if value is not None: + parts[attr] = value + if parts: + return parts + try: + return str(timeout) + except Exception: + return None + + +def _serialize_result(result: Any) -> Any: + """Serializa o retorno da função para um formato compatível com Langfuse.""" + if hasattr(result, "model_dump"): + return result.model_dump() + if hasattr(result, "dict"): + return result.dict() + return result + + +def _extract_session_id(state: Any) -> Optional[str]: + """Extrai session_id do AgentState.""" + try: + if isinstance(state, dict): + if state.get("session_id"): + return state["session_id"] + metadata = state.get("metadata") + else: + val = getattr(state, "session_id", None) + if val: + return val + metadata = getattr(state, "metadata", None) + + if metadata is not None: + if hasattr(metadata, "session_id") and metadata.session_id: + return metadata.session_id + if isinstance(metadata, dict) and metadata.get("session_id"): + return metadata["session_id"] + return None + except Exception: + return None + + +def trace_node(func: Callable[..., Any]) -> Callable[..., Any]: + """Decorator que instrumenta nós com Langfuse SDK e anexa ICs. + + Usa langfuse.get_client().start_as_current_observation para criar spans + automaticamente com hierarquia correta. + Ao final da execução, anexa os ICs coletados como metadata no span. + Se o SDK não estiver disponível, opera como no-op transparente. + + Args: + func: Função de nó com assinatura (state: AgentState) -> AgentState. + + Returns: + Função envolvida com a mesma assinatura. + """ + module = func.__module__ or "" + module_short = module.split(".")[-1] + func_name = func.__name__ + if module_short and module_short != "__main__": + node_name = module_short + else: + node_name = func_name + + is_async = asyncio.iscoroutinefunction(func) + + if is_async: + @wraps(func) + async def async_wrapper(state: Any) -> Any: + session_id = _extract_session_id(state) + async with log_operation(func_name, component=node_name, logger=logger) as op: + if isinstance(state, dict): + messages = state.get("messages") + if messages is not None: + op.add_field("message_count", len(messages)) + iteration = state.get("iteration_count") + if iteration is not None: + op.add_field("iteration_count", iteration) + try: + from langfuse import get_client + lf = get_client() + with lf.start_as_current_observation( + as_type="span", + name=node_name, + input=state if isinstance(state, dict) else None, + ) as obs: + result = await func(state) + ics = ICsCollector.get_current(session_id) if session_id else [] + logger.debug("trace_node %s: session=%s ics_count=%d", node_name, session_id, len(ics)) + error_in_state = result.get("error") if isinstance(result, dict) else None + if error_in_state: + obs.update( + output=result, + level="ERROR", + status_message=f"[{error_in_state.get('type', 'Error')}] {error_in_state.get('message', '')}", + metadata={"ics": ics, "session_id": session_id}, + ) + else: + obs.update( + output=result if isinstance(result, dict) else None, + metadata={"ics": ics, "session_id": session_id}, + ) + return result + except ImportError: + return await func(state) + + return async_wrapper + + else: + @wraps(func) + def sync_wrapper(state: Any) -> Any: + session_id = _extract_session_id(state) + with log_operation(func_name, component=node_name, logger=logger) as op: + if isinstance(state, dict): + messages = state.get("messages") + if messages is not None: + op.add_field("message_count", len(messages)) + iteration = state.get("iteration_count") + if iteration is not None: + op.add_field("iteration_count", iteration) + try: + from langfuse import get_client + lf = get_client() + with lf.start_as_current_observation( + as_type="span", + name=node_name, + input=state if isinstance(state, dict) else None, + ) as obs: + result = func(state) + ics = ICsCollector.get_current(session_id) if session_id else [] + logger.debug("trace_node %s: session=%s ics_count=%d", node_name, session_id, len(ics)) + error_in_state = result.get("error") if isinstance(result, dict) else None + if error_in_state: + obs.update( + output=result, + level="ERROR", + status_message=f"[{error_in_state.get('type', 'Error')}] {error_in_state.get('message', '')}", + metadata={"ics": ics, "session_id": session_id}, + ) + else: + obs.update( + output=result if isinstance(result, dict) else None, + metadata={"ics": ics, "session_id": session_id} if ics else {"session_id": session_id}, + ) + return result + except ImportError: + return func(state) + + return sync_wrapper + +def trace_api(func: Callable[..., Any]) -> Callable[..., Any]: + """Decorator que registra chamadas de API externa (Siebel, IMDB) no Langfuse. + + Cria um span as_type="tool" para dar visibilidade às chamadas externas. + """ + is_async = asyncio.iscoroutinefunction(func) + + api_name = func.__name__ + module_short = (func.__module__ or "").split(".")[-1] + + if is_async: + @wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + async with log_operation(api_name, component=module_short, logger=logger): + try: + from langfuse import get_client + lf = get_client() + with lf.start_as_current_observation( + as_type="tool", + name=api_name, + input={"args": list(args), "kwargs": kwargs} if (args or kwargs) else None, + ) as obs: + try: + result = await func(*args, **kwargs) + obs.update(output=result) + return result + except Exception as exc: + obs.update(level="ERROR", status_message=str(exc)) + raise + except ImportError: + return await func(*args, **kwargs) + return async_wrapper + + else: + @wraps(func) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + with log_operation(api_name, component=module_short, logger=logger): + try: + from langfuse import get_client + lf = get_client() + with lf.start_as_current_observation( + as_type="tool", + name=api_name, + input={"args": list(args), "kwargs": kwargs} if (args or kwargs) else None, + ) as obs: + try: + result = func(*args, **kwargs) + obs.update(output=result) + return result + except Exception as exc: + obs.update(level="ERROR", status_message=str(exc)) + raise + except ImportError: + return func(*args, **kwargs) + return sync_wrapper + + +def trace_tool(func: Callable[..., Any]) -> Callable[..., Any]: + """Decorator que registra chamadas de tool como observation no Langfuse. + + Aplica em _run e _arun de qualquer BaseTool. Cria um span as_type="tool" + filho do span ativo (nó que chamou a tool), registrando input, output e erros. + + Args: + func: Método _run ou _arun da tool. + + Returns: + Método envolvido com a mesma assinatura. + """ + is_async = asyncio.iscoroutinefunction(func) + + method_name = func.__name__ + + if is_async: + @wraps(func) + async def async_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + tool_name = f"{self.__class__.__name__}.{method_name}" + filtered_kwargs = {key: value for key, value in kwargs.items() if key != "run_manager"} + obs_input = { + "args": list(args), + **filtered_kwargs, + } if (args or filtered_kwargs) else None + async with log_operation(method_name, component=self.__class__.__name__, logger=logger): + try: + from langfuse import get_client + lf = get_client() + with lf.start_as_current_observation( + as_type="tool", + name=tool_name, + input=obs_input, + ) as obs: + try: + result = await func(self, *args, **kwargs) + metadata = _tool_call_metadata.get({}) + _tool_call_metadata.set({}) + update_http_observation( + obs, metadata, obs_input, result=result, + ) + return result + except Exception as exc: + metadata = _tool_call_metadata.get({}) + _tool_call_metadata.set({}) + update_http_observation( + obs, metadata, obs_input, exc=exc, + ) + _flush_failed_tool_span(lf) + raise + except ImportError: + return await func(self, *args, **kwargs) + return async_wrapper + + else: + @wraps(func) + def sync_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + tool_name = f"{self.__class__.__name__}.{method_name}" + filtered_kwargs = {key: value for key, value in kwargs.items() if key != "run_manager"} + obs_input = { + "args": list(args), + **filtered_kwargs, + } if (args or filtered_kwargs) else None + with log_operation(method_name, component=self.__class__.__name__, logger=logger): + try: + from langfuse import get_client + lf = get_client() + with lf.start_as_current_observation( + as_type="tool", + name=tool_name, + input=obs_input, + ) as obs: + try: + result = func(self, *args, **kwargs) + metadata = _tool_call_metadata.get({}) + _tool_call_metadata.set({}) + update_http_observation( + obs, metadata, obs_input, result=result, + ) + return result + except Exception as exc: + metadata = _tool_call_metadata.get({}) + _tool_call_metadata.set({}) + update_http_observation( + obs, metadata, obs_input, exc=exc, + ) + _flush_failed_tool_span(lf) + raise + except ImportError: + return func(self, *args, **kwargs) + return sync_wrapper + + +def _flush_failed_tool_span(lf: Any) -> None: + """Force-flush the just-ended failed tool span to Langfuse. + + Why: when a tool raises, the OTEL BatchSpanProcessor buffers the span. + If the request completes (handler returns) before the next batch is + exported, the error span is dropped — making timeout/error retries + invisible in Langfuse while the eventual successful retry shows up + because the parent trace's later flush_trace() catches it. + Doing a targeted flush here keeps error visibility deterministic. + """ + try: + from opentelemetry import trace as otel_trace + tracer_provider = otel_trace.get_tracer_provider() + if hasattr(tracer_provider, "force_flush"): + tracer_provider.force_flush(timeout_millis=2000) + except Exception: + pass + try: + if lf is not None: + lf.flush() + except Exception: + pass + + +def update_http_observation( + obs: Any, + metadata: dict, + obs_input: Optional[dict], + *, + result: Any = None, + exc: Optional[BaseException] = None, +) -> None: + """Enrich the active observation with captured HTTP metadata. + + Input merges the tool's original args/kwargs with the HTTP request side + (endpoint, method, headers, body, timeout). Output carries the HTTP + response side (status, headers, body, latency, version) plus, on error, + an `error` field. Tools without HTTP traffic fall back to the minimal + args/result shape. + """ + has_http = bool(metadata) and metadata.get("endpoint") is not None + + if has_http: + new_input: dict = dict(obs_input) if obs_input else {} + new_input.update({ + "endpoint": metadata.get("endpoint"), + "method": metadata.get("method"), + "request_headers": metadata.get("request_headers"), + "request_body": metadata.get("request_body"), + "timeout": metadata.get("timeout"), + }) + + new_output: dict = { + "status_code": metadata.get("status_code"), + "response_headers": metadata.get("response_headers"), + "latency_ms": metadata.get("latency_ms"), + "http_version": metadata.get("http_version"), + "response": metadata.get("response_body"), + } + if exc is not None: + new_output["error"] = str(exc) + obs.update( + input=new_input, + output=new_output, + level="ERROR", + status_message=f"[{type(exc).__name__}] {exc}", + ) + else: + obs.update(input=new_input, output=new_output) + return + + if exc is not None: + obs.update( + level="ERROR", + status_message=f"[{type(exc).__name__}] {exc}", + output={"error": str(exc)}, + ) + else: + obs.update(output=_serialize_result(result)) + + +def score_current_trace(name: str, value: float, comment: str = "") -> None: + """Registra um score no trace ativo do Langfuse. + + Extrai o trace_id do contexto OTEL ativo e associa o score ao trace correto. + Se o SDK não estiver disponível ou não houver span ativo, opera como no-op silencioso. + + Args: + name: Nome do score (ex: "decision_valid", "triplet_valid"). + value: Valor numérico do score (0.0 a 1.0). + comment: Comentário opcional. + """ + try: + from opentelemetry import trace as otel_trace + from langfuse import get_client + + current_span = otel_trace.get_current_span() + span_context = current_span.get_span_context() + if not span_context or not span_context.is_valid: + logger.warning("score_current_trace(%s): nenhum span OTEL ativo, score descartado", name) + return + + trace_id = format(span_context.trace_id, "032x") + logger.info("score_current_trace(%s): trace_id=%s value=%s", name, trace_id, value) + lf = get_client() + lf.create_score( + trace_id=trace_id, + name=name, + value=value, + comment=comment, + ) + logger.info("score_current_trace(%s): create_score() chamado com sucesso", name) + except Exception as exc: + logger.error("score_current_trace(%s): erro ao registrar score: %s", name, exc, exc_info=True) + + +def flush_trace() -> None: + """Força o envio imediato de todos os spans e logs pendentes. + + Deve ser chamado após o encerramento de um trace, especialmente em fluxos + que terminam rapidamente (ex: erros de validação sem chamadas LLM), onde o + BatchSpanProcessor pode não ter feito flush automático ainda. + """ + try: + from opentelemetry import trace as otel_trace + tracer_provider = otel_trace.get_tracer_provider() + if hasattr(tracer_provider, "force_flush"): + tracer_provider.force_flush(timeout_millis=5000) + except Exception: + pass + + # Flush OTel log records (NOC events via BatchLogRecordProcessor). + # Without this, events sit in the buffer for up to 5 s after the request. + try: + from opentelemetry._logs import get_logger_provider + lp = get_logger_provider() + if hasattr(lp, "force_flush"): + lp.force_flush(timeout_millis=2000) + except Exception: + pass + + # Flush the Langfuse SDK's internal score/event queue separately — + # lf.score() goes through the SDK task manager, not through OTEL spans. + try: + from langfuse import get_client + lf = get_client() + lf.flush() + except Exception: + pass + + +def _build_langfuse_httpx_client(): + """Build an httpx.Client honoring settings.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE.""" + from src.core.config import settings + cert_path = settings.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE + if not cert_path: + return None + try: + import httpx + return httpx.Client(verify=cert_path) + except Exception as exc: + logger.warning("Failed to build httpx client for Langfuse: %s", exc) + return None + + +def setup_observer() -> None: + """Configura o Observer SDK e inicializa o Langfuse SDK. + + O Observer coleta ICs via monkey_patch_observer(). + O Langfuse SDK lê LANGFUSE_PUBLIC_KEY/SECRET_KEY/HOST do ambiente + e instrumenta automaticamente via OTEL. + """ + configure({ + "publisher": {"buffer_size": 10}, + "sampling_rate": 1.0, + "log_level": "INFO", + }) + + monkey_patch_observer() + + # Inicializa o SDK — lê credenciais do ambiente automaticamente + try: + from src.core.config import settings + if settings.LANGFUSE_PUBLIC_KEY: + os.environ.setdefault("LANGFUSE_PUBLIC_KEY", settings.LANGFUSE_PUBLIC_KEY) + if settings.LANGFUSE_SECRET_KEY: + os.environ.setdefault("LANGFUSE_SECRET_KEY", settings.LANGFUSE_SECRET_KEY) + if settings.LANGFUSE_BASE_URL: + # Re-use the already setup host from settings.setup_langfuse() + os.environ.setdefault("LANGFUSE_HOST", os.environ.get("LANGFUSE_HOST") or settings.LANGFUSE_BASE_URL) + os.environ.setdefault( + "OTEL_EXPORTER_OTLP_ENDPOINT", + (settings.LANGFUSE_BASE_URL).rstrip("/") + "/api/public/otel", + ) + logger.debug(f"Initializing Langfuse SDK at: {settings.LANGFUSE_BASE_URL}") + from langfuse import Langfuse + httpx_client = _build_langfuse_httpx_client() + if httpx_client is not None: + Langfuse(httpx_client=httpx_client) + logger.info("Langfuse SDK initialized with custom httpx client (OTLP certificate)") + else: + Langfuse() + logger.info("Langfuse SDK initialized") + except Exception as e: + logger.debug("Langfuse SDK not available: %s", e) + + logger.info("Observer configured (Langfuse SDK + @observe)") diff --git a/src/utils/text.py b/src/utils/text.py new file mode 100644 index 0000000..c3bd4c3 --- /dev/null +++ b/src/utils/text.py @@ -0,0 +1,23 @@ +def truncate_text(text: str, limit: int, suffix: str = "") -> str: + """ + Truncates a string to a specified limit. + + Args: + text: The string to truncate. + limit: The maximum character count. + suffix: Optional suffix to add if truncated (defaults to empty string). + + Returns: + The truncated string. + """ + if not text: + return "" + + if len(text) <= limit: + return text + + # If suffix is provided, we need to account for its length + if suffix: + return text[:limit - len(suffix)] + suffix + + return text[:limit] diff --git a/tests_original_develop/.DS_Store b/tests_original_develop/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a62d2dc907bf0442bd8bfacff64012e9e12f5574 GIT binary patch literal 6148 zcmeHKy-veG4EB`_6<x{(TV4Q(EU++yDm+0aHYg3HQrlFJO0eb8*mxG+g3tCTZJM0K zR8`29&fmp9cg`2}+#@14+DsFoArbX47^73D4q-o!mh`-Zlbx)wp*PCueKAYMt!OhC z1IECyF`x&)3(e_+O4|ATJ{QZi7JGIB?9*P%vdFT@qL_$}s)yU3@4N1}>7U}(ugQ43 z=M`@~KvgSLmD3F8$`fXDDrm*0%QliO!jXX*1M4n}&PoH(&3c(eaI{991#}K9>2t5r z%dVI}jjgo;)wSX}>Wc7PSKzK})YH4>GrIN#>e%sjvQzsT`}V%Dw8@b(fS%3LA1InO z28;n?pk{#2hZut~^;jv&(E%k_0AK{O2<FlYfiW9^smDqY7KqbSpr$&1#c-Mqd+g&< zkCmdPlarmXj^Ek&8;X<NVUH!8T&if=7%&E!44mlWko*6A`2OEavL|D}82DEVxF{QE zBfOIDt*w{iUK?XPVz6*rr6?&-z8%YiTk$!D2z)V5fT_ny5f+I35fB<o83PAp;1@{t Bj|>0+ literal 0 HcmV?d00001 diff --git a/tests_original_develop/__init__.py b/tests_original_develop/__init__.py new file mode 100644 index 0000000..79a3579 --- /dev/null +++ b/tests_original_develop/__init__.py @@ -0,0 +1 @@ +"""Test suite for Python Agent Boilerplate.""" diff --git a/tests_original_develop/conftest.py b/tests_original_develop/conftest.py new file mode 100644 index 0000000..4347f76 --- /dev/null +++ b/tests_original_develop/conftest.py @@ -0,0 +1,37 @@ +""" +Pytest configuration and fixtures for all tests. +""" + +# Stub google.cloud.pubsub_v1 BEFORE any other import. `agent_framework` +# (private TIM package) instantiates a PublisherClient at import time, +# which requires GCloud creds; tests run without those. +import sys as _sys +from unittest.mock import MagicMock as _MagicMock +_sys.modules.setdefault("google.cloud.pubsub_v1", _MagicMock()) + +# Load .env BEFORE any other import so that Settings fields that call +# os.environ[] at class-definition time (e.g. TAIS_DB_DSN) are satisfied. +from dotenv import load_dotenv +load_dotenv() + +import os +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def setup_test_environment(): + """ + Set up environment variables for testing. + + This fixture runs once per test session and sets required + environment variables to allow config module to load. + """ + # Set required environment variables for Settings + os.environ.setdefault("LLM_PROVIDER", "openai") + os.environ.setdefault("LLM_MODEL", "gpt-4") + os.environ.setdefault("LOG_LEVEL", "INFO") + os.environ.setdefault("LOG_FORMAT", "text") + + yield + + # Cleanup is not needed as these are just defaults diff --git a/tests_original_develop/fixtures/__init__.py b/tests_original_develop/fixtures/__init__.py new file mode 100644 index 0000000..8c6745f --- /dev/null +++ b/tests_original_develop/fixtures/__init__.py @@ -0,0 +1 @@ +"""Test fixtures for the agent boilerplate.""" diff --git a/tests_original_develop/fixtures/mock_llm.py b/tests_original_develop/fixtures/mock_llm.py new file mode 100644 index 0000000..f4508b6 --- /dev/null +++ b/tests_original_develop/fixtures/mock_llm.py @@ -0,0 +1,155 @@ +"""Mock LLM implementations for testing without API calls.""" + +from typing import List, Optional, Any +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.language_models import BaseChatModel +from langchain_core.outputs import ChatGeneration, ChatResult +import pytest + + +class MockLLM(BaseChatModel): + """ + Mock LLM for testing without making actual API calls. + + This mock can be configured with predefined responses and tracks + the number of times it's been called. + + Example: + >>> mock = MockLLM(responses=["Hello!", "How can I help?"]) + >>> response = await mock.ainvoke([HumanMessage(content="Hi")]) + >>> assert response.content == "Hello!" + """ + + responses: List[str] + call_count: int = 0 + + def __init__(self, responses: Optional[List[str]] = None, **kwargs): + """ + Initialize mock LLM with predefined responses. + + Args: + responses: List of responses to return in sequence. + If None, returns "Mock response" for all calls. + """ + super().__init__(**kwargs) + self.responses = responses or ["Mock response"] + self.call_count = 0 + + @property + def _llm_type(self) -> str: + """Return identifier for this LLM type.""" + return "mock" + + def _generate( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + **kwargs: Any, + ) -> ChatResult: + """Generate a mock response synchronously.""" + response = self.responses[self.call_count % len(self.responses)] + self.call_count += 1 + + message = AIMessage(content=response) + generation = ChatGeneration(message=message) + + return ChatResult(generations=[generation]) + + async def _agenerate( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + **kwargs: Any, + ) -> ChatResult: + """Generate a mock response asynchronously.""" + return self._generate(messages, stop, **kwargs) + + def reset(self): + """Reset call count for reuse in tests.""" + self.call_count = 0 + + +class MockLLMWithError(BaseChatModel): + """ + Mock LLM that raises errors for testing error handling. + + Example: + >>> mock = MockLLMWithError(error_message="API timeout") + >>> with pytest.raises(Exception): + ... await mock.ainvoke([HumanMessage(content="Hi")]) + """ + + error_message: str + + def __init__(self, error_message: str = "Mock LLM error", **kwargs): + """ + Initialize mock LLM that raises errors. + + Args: + error_message: Error message to raise + """ + super().__init__(**kwargs) + self.error_message = error_message + + @property + def _llm_type(self) -> str: + """Return identifier for this LLM type.""" + return "mock_error" + + def _generate( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + **kwargs: Any, + ) -> ChatResult: + """Raise an error.""" + raise Exception(self.error_message) + + async def _agenerate( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + **kwargs: Any, + ) -> ChatResult: + """Raise an error asynchronously.""" + raise Exception(self.error_message) + + +@pytest.fixture +def mock_llm(): + """ + Pytest fixture providing a basic mock LLM. + + Returns: + MockLLM instance with default response + """ + return MockLLM() + + +@pytest.fixture +def mock_llm_with_responses(): + """ + Pytest fixture factory for creating mock LLM with custom responses. + + Returns: + Function that creates MockLLM with specified responses + + Example: + def test_conversation(mock_llm_with_responses): + llm = mock_llm_with_responses(["Hi!", "Goodbye!"]) + # Use llm in test + """ + def _create_mock(responses: List[str]) -> MockLLM: + return MockLLM(responses=responses) + return _create_mock + + +@pytest.fixture +def mock_llm_with_error(): + """ + Pytest fixture providing a mock LLM that raises errors. + + Returns: + MockLLMWithError instance + """ + return MockLLMWithError() diff --git a/tests_original_develop/integration/__init__.py b/tests_original_develop/integration/__init__.py new file mode 100644 index 0000000..c210fac --- /dev/null +++ b/tests_original_develop/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests.""" diff --git a/tests_original_develop/property/__init__.py b/tests_original_develop/property/__init__.py new file mode 100644 index 0000000..0cde5da --- /dev/null +++ b/tests_original_develop/property/__init__.py @@ -0,0 +1 @@ +"""Property-based tests using Hypothesis.""" diff --git a/tests_original_develop/unit/__init__.py b/tests_original_develop/unit/__init__.py new file mode 100644 index 0000000..e0310a0 --- /dev/null +++ b/tests_original_develop/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests.""" diff --git a/tests_original_develop/unit/test_anatel_validation.py b/tests_original_develop/unit/test_anatel_validation.py new file mode 100644 index 0000000..c0f1138 --- /dev/null +++ b/tests_original_develop/unit/test_anatel_validation.py @@ -0,0 +1,141 @@ +import pytest +from src.api.schemas.anatel_schemas import ( + TicketRequestEvent, ReasonCode, ERROR_CODE_MAPPING, + Customer, Address, Subscriber, Complaint, Origin, SubmittedBy +) +from src.agent.logic.validation import validate_required_fields +from datetime import datetime + +# ---- Fixture compartilhada ---------------------------------------- + +@pytest.fixture +def valid_ticket_payload(): + """Retorna um payload compatível com TicketRequestEvent (Swagger v0.0.5)""" + return { + "caseType": "anatel", + "origin": { + "sourceSystem": "test", + "submittedBy": {"userId": "u1", "name": "n1"} + }, + "ticketId": "DS-98765", + "transactionId": "trans-123", + "customer": { + "cpfCnpj": "12345678901", + "phones": ["+5511999999999"], + "msisdn": "5511999999999", + "name": "Cliente Exemplo", + "odcCustomer": False, + "contumazCustomer": False, + "address": { + "cep": "01001-000", + "street": "Praca da Se", + "neighborhood": "Se", + "city": "Sao Paulo", + "state": "SP" + }, + "subscriber": { + "cpfCnpj": "12345678901", + "subscriberName": "Parente do Cliente" + } + }, + "complaint": { + "complaintProtocol": "202603270001", + "actionType": "nova", + "inputChannel": "Anatel", + "service": "S", + "firstService": "FS", + "modality": "M", + "motive": "M", + "description": "Descricao da reclamacao", + "openedAt": datetime.now() + } + } + +# ---- Testes de caminho feliz (happy path) -------------------------- + +def test_validate_valid_ticket(valid_ticket_payload): + """Valida um ticket 100% preenchido conforme o novo contrato.""" + ticket = TicketRequestEvent(**valid_ticket_payload) + result = validate_required_fields(ticket) + assert result.is_valid is True + +# ---- Testes de cobertura total de ReasonCodes (Exaustivo) ----------- + +@pytest.mark.parametrize("loc_tuple, mapping_val", ERROR_CODE_MAPPING.items()) +def test_exhaustive_reason_code_coverage(valid_ticket_payload, loc_tuple, mapping_val): + """ + Testa cada entrada do ERROR_CODE_MAPPING removendo o campo correspondente + e verificando se o validate_required_fields retorna o ReasonCode e a mensagem esperados. + """ + reason_code, expected_text = mapping_val + + # Navega no dicionário para remover o campo + data_ptr = valid_ticket_payload + for part in loc_tuple[:-1]: + data_ptr = data_ptr[part] + + # Remove ou limpa o campo + field_to_clear = loc_tuple[-1] + data_ptr[field_to_clear] = "" if isinstance(data_ptr[field_to_clear], str) else None + + # Reconstrói os modelos aninhados usando model_construct para evitar o Pydantic + # mas permitir acesso por atributo (.) na lógica de validação manual + address = Address.model_construct(**valid_ticket_payload["customer"]["address"]) + + cust_data = valid_ticket_payload["customer"].copy() + cust_data.pop("address", None) + cust_data.pop("subscriber", None) + + sub_data = valid_ticket_payload["customer"]["subscriber"].copy() + subscriber = Subscriber.model_construct(**sub_data) + + customer = Customer.model_construct( + address=address, + subscriber=subscriber, + **cust_data + ) + + comp_data = valid_ticket_payload["complaint"].copy() + complaint = Complaint.model_construct(**comp_data) + + orig_data = valid_ticket_payload["origin"].copy() + sub_by_data = orig_data.pop("submittedBy", {}) + sub_by = SubmittedBy.model_construct(**sub_by_data) + origin = Origin.model_construct( + submittedBy=sub_by, + **orig_data + ) + + top_data = valid_ticket_payload.copy() + top_data.pop("customer", None) + top_data.pop("complaint", None) + top_data.pop("origin", None) + + ticket = TicketRequestEvent.model_construct( + customer=customer, + complaint=complaint, + origin=origin, + **top_data + ) + + result = validate_required_fields(ticket) + + assert result.is_valid is False + codes = [m["code"] for m in result.error_response["detail"]["messages"]] + texts = [m["text"] for m in result.error_response["detail"]["messages"]] + + assert reason_code.value in codes + assert expected_text in texts + +def test_validate_error_response_format(valid_ticket_payload): + """Verifica se o formato do erro segue o padrão Swagger (title, status, detail).""" + valid_ticket_payload["complaint"]["motive"] = "" + ticket = TicketRequestEvent(**valid_ticket_payload) + result = validate_required_fields(ticket) + assert result.is_valid is False + # Verificando formato fixo exigido pela TIM/Swagger + assert result.error_response["title"] == "validation error" + assert result.error_response["status"] == 400 + assert "detail" in result.error_response + assert "messages" in result.error_response["detail"] + assert isinstance(result.error_response["detail"]["messages"], list) \ No newline at end of file diff --git a/tests_original_develop/unit/test_canceling_analysis_duplicate.py b/tests_original_develop/unit/test_canceling_analysis_duplicate.py new file mode 100644 index 0000000..6fba4e9 --- /dev/null +++ b/tests_original_develop/unit/test_canceling_analysis_duplicate.py @@ -0,0 +1,461 @@ +"""Unit tests for the duplicate-detection branch in `canceling_analysis_node`.""" + +import json +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import patch, MagicMock + +import pytest + +from src.agent.nodes.canceling_analysis_node import ( + DuplicateAnalysisError, + _analyze_duplicate, + perform_canceling_analysis, +) +from src.api.schemas.anatel_schemas import ( + ComplaintHistoryItem, + TicketRequestEvent, +) + + +@pytest.fixture +def base_payload(): + return { + "caseType": "anatel", + "origin": {"sourceSystem": "test", "submittedBy": {"userId": "u1", "name": "n1"}}, + "ticketId": "DS-1", + "transactionId": "trx-1", + "customer": { + "cpfCnpj": "12345678901", + "phones": ["+5511988887777"], + "msisdn": "5511988887777", + "name": "Cliente Teste", + "odcCustomer": False, + "contumazCustomer": False, + "address": { + "cep": "01001-000", + "street": "Rua", + "neighborhood": "Bairro", + "city": "Cidade", + "state": "SP", + }, + "subscriber": {"cpfCnpj": "12345678901", "subscriberName": "Cliente Teste"}, + }, + "complaint": { + "complaintProtocol": "202603270001", + "actionType": "nova", + "inputChannel": "Anatel", + "service": "Celular Pós-pago", + "firstService": "Celular Pós-pago", + "modality": "Cobrança", + "motive": "Cobrança indevida", + "description": "Reclamação atual descrição completa", + "openedAt": "2026-02-15T10:30:00-03:00", + }, + } + + +def test_schema_complaint_history_defaults_to_none(base_payload): + """Payload without complaintHistory loads as None; node normalizes to [].""" + ticket = TicketRequestEvent(**base_payload) + assert ticket.complaintHistory is None + + +def test_schema_accepts_complaint_history(base_payload): + """ComplaintHistoryItem accepts the contract specified by business.""" + base_payload["complaintHistory"] = [ + { + "complaintProtocol": "202601150001234", + "status": "A Cancelar", + "actionType": "nova", + "providerProtocol": None, + "openedAt": "2026-01-15T10:30:00-03:00", + "inputChannel": "Anatel", + "service": "Celular Pós-pago", + "firstService": "Celular Pós-pago", + "modality": "Cobrança", + "motive": "Cobrança após cancelamento", + "description": "Cliente reclamou de cobrança indevida em janeiro.", + "cpfCnpj": "124526359752", + "msisdn": "551198887777", + "phones": ["+55 (11) 98888-7777"], + } + ] + ticket = TicketRequestEvent(**base_payload) + assert len(ticket.complaintHistory) == 1 + item = ticket.complaintHistory[0] + assert isinstance(item, ComplaintHistoryItem) + assert item.complaintProtocol == "202601150001234" + assert item.phones == ["+55 (11) 98888-7777"] + + +def _llm_response(payload: dict) -> SimpleNamespace: + """Build a stub LLMResponse-shaped object. + + Mirrors the contract of `LLMResponse` after `chat_llm_with_usage(..., expect_json=True)`: + `content` carries the raw text and `parsed_json` carries the already-decoded dict. + """ + return SimpleNamespace( + content=json.dumps(payload), + usage={"input": 1, "output": 1, "total": 2}, + parsed_json=payload, + ) + + +def _state_with(history=None, complaint_overrides=None): + """Minimal AgentState for the canceling_analysis node.""" + history = history if history is not None else [] + complaint = { + "service": "Celular Pós-pago", + "firstService": "Celular Pós-pago", + "modality": "Cobrança", + "motive": "Cobrança indevida", + "description": "Cliente reclama de cobrança indevida no mês de fevereiro.", + "complaintProtocol": "202602150001", + "openedAt": "2026-02-15T10:30:00-03:00", + } + if complaint_overrides: + complaint.update(complaint_overrides) + + customer = { + "cpfCnpj": "12345678901", + "msisdn": "5511988887777", + "phones": ["+5511988887777"], + } + request_context = { + "complaint": complaint, + "customer": customer, + "complaintHistory": history, + } + return { + "messages": [], + "current_step": "knowledge_base_enrichment_unavailable", + "iteration_count": 0, + "tool_results": None, + "final_response": None, + "session_id": "sess-test", + "metadata": {"request_context": request_context}, + "error": None, + } + + +@pytest.mark.asyncio +async def test_offensive_short_circuits_duplicate_check(): + """When the integrity check decides 'cancelar', duplicate analysis must NOT run.""" + state = _state_with(history=[{"complaintProtocol": "X", "openedAt": "2026-02-10"}]) + + canceling_response = _llm_response({ + "decision": "cancelar", + "reasoning": "Conteúdo ofensivo detectado.", + "cancel_reason": "Conteúdo Ofensivo", + }) + + with patch( + "src.agent.nodes.canceling_analysis_node.chat_llm_with_usage", + return_value=canceling_response, + ) as mock_llm: + result = await perform_canceling_analysis(state) + + # Only the canceling LLM was called — duplicate analysis was skipped. + assert mock_llm.call_count == 1 + ctx = result["metadata"]["request_context"] + assert ctx["siebel_action"] == "cancelar" + assert ctx["cancel_reason"] == "Conteúdo Ofensivo" + assert ctx.get("duplicate_match") is None + assert result["current_step"] == "canceling_analysis_cancel_ticket" + + +@pytest.mark.asyncio +async def test_empty_history_short_circuits_to_nova(): + """Empty complaintHistory skips the duplicate LLM call (deterministic 'nova').""" + state = _state_with(history=[]) + + cancel_resp = _llm_response({ + "decision": "continuar", + "reasoning": "OK", + "cancel_reason": None, + }) + + with patch( + "src.agent.nodes.canceling_analysis_node.chat_llm_with_usage", + side_effect=[cancel_resp], + ) as mock_llm: + result = await perform_canceling_analysis(state) + + # Only the canceling LLM was called — duplicate detection short-circuited. + assert mock_llm.call_count == 1 + ctx = result["metadata"]["request_context"] + assert ctx.get("siebel_action") is None + assert result["current_step"] == "proceed_graph" + canceling_decision = ctx["canceling_decision"] + assert canceling_decision["decision"] == "continuar" + assert "duplicate_match" not in canceling_decision + assert "duplicity_analysis" not in canceling_decision + # No history: reasoning carries only the integrity-check negative. + assert canceling_decision["reasoning"] == "OK" + + +@pytest.mark.asyncio +async def test_continuar_then_nova_with_non_empty_history_proceeds(): + """Both checks pass on non-empty history: LLM runs, no cancellation.""" + history = [{ + "complaintProtocol": "202512010001", + "status": "Encerrada", + "actionType": "nova", + "openedAt": "2025-12-01T10:30:00-03:00", + "service": "Celular Pós-pago", + "modality": "Cobrança", + "motive": "Outro motivo", + "description": "Reclamação antiga sobre tema distinto.", + "cpfCnpj": "12345678901", + "msisdn": "5511988887777", + "phones": ["+5511988887777"], + }] + state = _state_with(history=history) + + cancel_resp = _llm_response({ + "decision": "continuar", + "reasoning": "Sem violação de política.", + "cancel_reason": None, + }) + duplicate_resp = _llm_response({ + "decision": "nova", + "reasoning": "Histórico com 1 item; pontuação total insuficiente.", + }) + + with patch( + "src.agent.nodes.canceling_analysis_node.chat_llm_with_usage", + side_effect=[cancel_resp, duplicate_resp], + ) as mock_llm: + result = await perform_canceling_analysis(state) + + assert mock_llm.call_count == 2 + ctx = result["metadata"]["request_context"] + assert ctx.get("siebel_action") is None + assert result["current_step"] == "proceed_graph" + canceling_decision = ctx["canceling_decision"] + assert canceling_decision["decision"] == "continuar" + assert "duplicate_match" not in canceling_decision + assert "duplicity_analysis" not in canceling_decision + # History + nova: reasoning joins integrity-check and duplicate-check negatives. + assert canceling_decision["reasoning"] == ( + "Sem violação de política. Histórico com 1 item; pontuação total insuficiente." + ) + + +@pytest.mark.asyncio +async def test_continuar_then_duplicada_cancels(): + """Duplicate detected: triplet applied, cancel_reason='Duplicidade', match recorded.""" + history = [{ + "complaintProtocol": "202601150001234", + "status": "Aberta", + "actionType": "nova", + "providerProtocol": None, + "openedAt": "2026-02-01T10:30:00-03:00", + "inputChannel": "anatel", + "service": "Celular Pós-pago", + "firstService": "Celular Pós-pago", + "modality": "Cobrança", + "motive": "Cobrança indevida", + "description": "Cliente reclama de cobrança indevida.", + "cpfCnpj": "12345678901", + "msisdn": "5511988887777", + "phones": ["+5511988887777"], + }] + state = _state_with(history=history) + + cancel_resp = _llm_response({ + "decision": "continuar", + "reasoning": "Sem violação.", + "cancel_reason": None, + }) + duplicate_resp = _llm_response({ + "decision": "duplicada", + "reasoning": ( + "Candidato sobrevivente com mesmo cliente e mesmo telefone reclamado " + "dentro da janela de 30 dias; pontuação total 92." + ), + "case_response": ( + "Solicito o cancelamento desta ID, pois existe reclamação idêntica " + "no protocolo 202601150001234, com mesmo cliente, mesmo número " + "reclamado e mesmo motivo." + ), + "duplicate_match": { + "matched_protocol": "202601150001234", + "similarity_percent": 92, + }, + }) + + with patch( + "src.agent.nodes.canceling_analysis_node.chat_llm_with_usage", + side_effect=[cancel_resp, duplicate_resp], + ) as mock_llm: + result = await perform_canceling_analysis(state) + + assert mock_llm.call_count == 2 + ctx = result["metadata"]["request_context"] + assert ctx["siebel_action"] == "cancelar" + assert ctx["cancel_reason"] == "Duplicidade" + # Duplicate telemetry is nested inside canceling_decision (not top-level). + canceling_decision = ctx["canceling_decision"] + assert canceling_decision["decision"] == "cancelar" + assert canceling_decision["cancel_reason"] == "Duplicidade" + # reasoning now carries the model's analytical text (replaces the removed `duplicity_analysis`). + assert "duplicity_analysis" not in canceling_decision + assert canceling_decision["reasoning"].startswith("Candidato sobrevivente") + assert canceling_decision["duplicate_match"]["matched_protocol"] == "202601150001234" + assert canceling_decision["duplicate_match"]["similarity_percent"] == 92 + # Triplet applied + assert ctx["reason1"] == "Processo Interno" + assert ctx["reason2"] == "Atendimento" + assert ctx["reason3"] == "Cancelamento ID Anatel" + # Reasoning truncated and persisted + assert ctx["canceling_reasoning"].startswith("Candidato sobrevivente") + # Simplified external text stored in a dedicated key, consumed only by the Duplicidade builder. + assert ctx["canceling_case_response_text"].startswith("Solicito o cancelamento") + assert "202601150001234" in ctx["canceling_case_response_text"] + assert len(ctx["canceling_case_response_text"]) <= 200 + assert result["current_step"] == "canceling_analysis_cancel_ticket" + + +@pytest.mark.asyncio +async def test_duplicate_analysis_fails_open(): + """LLM exception during duplicate analysis must NOT cancel a legitimate ticket.""" + state = _state_with(history=[{ + "complaintProtocol": "999", + "openedAt": "2026-02-10T10:30:00-03:00", + }]) + + cancel_resp = _llm_response({ + "decision": "continuar", + "reasoning": "OK", + "cancel_reason": None, + }) + + def _llm_side_effect(*_args, **_kwargs): + # First call: integrity check returns continuar; second call: blow up. + if not hasattr(_llm_side_effect, "_called"): + _llm_side_effect._called = True + return cancel_resp + raise RuntimeError("LLM service unavailable") + + with patch( + "src.agent.nodes.canceling_analysis_node.chat_llm_with_usage", + side_effect=_llm_side_effect, + ): + result = await perform_canceling_analysis(state) + + ctx = result["metadata"]["request_context"] + # Fail-open: no cancellation triggered, ticket continues normally. + assert ctx.get("siebel_action") is None + assert ctx.get("duplicate_match") is None + assert ctx.get("cancel_reason") is None + assert result["current_step"] == "proceed_graph" + canceling_decision = ctx["canceling_decision"] + assert canceling_decision["decision"] == "continuar" + assert "duplicity_analysis" not in canceling_decision + assert "duplicate_match" not in canceling_decision + # History + fail-open: reasoning combines integrity-check negative with the synthetic unavailable text. + assert canceling_decision["reasoning"].startswith("OK") + assert "Análise automática de duplicidade indisponível" in canceling_decision["reasoning"] + + +@pytest.mark.asyncio +async def test_duplicate_analysis_unknown_decision_fails_open(): + """LLM returning an unknown decision string is treated like a failure (fail-open).""" + state = _state_with(history=[{ + "complaintProtocol": "999", + "openedAt": "2026-02-10T10:30:00-03:00", + }]) + + cancel_resp = _llm_response({ + "decision": "continuar", + "reasoning": "OK", + "cancel_reason": None, + }) + bogus_resp = _llm_response({ + "decision": "talvez", # invalid + "reasoning": "incerto", + "duplicate_match": None, + }) + + with patch( + "src.agent.nodes.canceling_analysis_node.chat_llm_with_usage", + side_effect=[cancel_resp, bogus_resp], + ): + result = await perform_canceling_analysis(state) + + ctx = result["metadata"]["request_context"] + assert ctx.get("siebel_action") is None + assert result["current_step"] == "proceed_graph" + + +@pytest.mark.asyncio +async def test_analyze_duplicate_payload_carries_history_fields(): + """The duplicate prompt must receive every field of the contract.""" + history_item = { + "complaintProtocol": "P1", + "status": "Aberta", + "actionType": "nova", + "providerProtocol": None, + "openedAt": "2026-02-10T10:30:00-03:00", + "inputChannel": "anatel", + "service": "Svc", + "firstService": "FS", + "modality": "M", + "motive": "Mt", + "description": "Desc histórica", + "cpfCnpj": "12345678901", + "msisdn": "5511988887777", + "phones": ["+5511988887777"], + } + context = { + "complaint": { + "service": "Svc", "firstService": "FS", "modality": "M", + "motive": "Mt", "description": "Desc atual", + "openedAt": "2026-02-15T10:30:00-03:00", + "complaintProtocol": "P2", + }, + "customer": { + "cpfCnpj": "12345678901", + "msisdn": "5511988887777", + "phones": ["+5511988887777"], + }, + "complaintHistory": [history_item], + } + + captured = {} + + def _capture(_llm, prompt: str, *_args, **_kwargs): + captured["prompt"] = prompt + return _llm_response({"decision": "nova", "reasoning": "ok", "duplicate_match": None}) + + with patch( + "src.agent.nodes.canceling_analysis_node.chat_llm_with_usage", + side_effect=_capture, + ): + parsed, meta = await _analyze_duplicate(context, session_id="sess-x") + + assert parsed["decision"] == "nova" + assert meta["prompt"] == captured["prompt"] + # Every field from the contract is forwarded to the LLM. + for field in ( + "complaintProtocol", "status", "actionType", "providerProtocol", + "openedAt", "inputChannel", "service", "firstService", "modality", + "motive", "description", "cpfCnpj", "msisdn", "phones", + ): + assert field in captured["prompt"], f"missing {field} in duplicate payload" + + +@pytest.mark.asyncio +async def test_analyze_duplicate_raises_on_invalid_decision(): + """The helper itself surfaces invalid decisions as DuplicateAnalysisError.""" + context = {"complaint": {}, "customer": {}, "complaintHistory": []} + bogus_resp = _llm_response({"decision": "?", "reasoning": "x"}) + + with patch( + "src.agent.nodes.canceling_analysis_node.chat_llm_with_usage", + return_value=bogus_resp, + ): + with pytest.raises(DuplicateAnalysisError): + await _analyze_duplicate(context, session_id="sess-x") diff --git a/tests_original_develop/unit/test_config.py b/tests_original_develop/unit/test_config.py new file mode 100644 index 0000000..2f2e747 --- /dev/null +++ b/tests_original_develop/unit/test_config.py @@ -0,0 +1,439 @@ +""" +Unit tests for configuration module. + +Tests specific examples and edge cases for configuration loading and validation. +""" + +import pytest +import tempfile +import os +from pathlib import Path +from pydantic import ValidationError + +from src.core.config import Settings, LLMConfig, AgentConfig + + +class TestSettings: + """Test Settings class.""" + + def test_settings_with_required_fields(self): + """Test that Settings can be created with required fields.""" + settings = Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4" + ) + + assert settings.LLM_PROVIDER == "openai" + assert settings.LLM_MODEL == "gpt-4" + assert settings.APP_NAME == "Agent Microservice" # default value + assert settings.DEBUG is False # default value + assert settings.CLASSIFICATION_LLM_MODEL == "bo_gptoss20b_dev" + assert settings.CLASSIFICATION_LLM_TEMPERATURE == 0.3 + + def test_settings_classification_custom_values(self): + """Test that Settings can be created with custom classification LLM values.""" + settings = Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + CLASSIFICATION_LLM_MODEL="custom_model", + CLASSIFICATION_LLM_TEMPERATURE=0.5, + CLASSIFICATION_LLM_MAX_TOKENS=512, + CLASSIFICATION_LLM_TOP_P=0.9 + ) + + assert settings.CLASSIFICATION_LLM_MODEL == "custom_model" + assert settings.CLASSIFICATION_LLM_TEMPERATURE == 0.5 + assert settings.CLASSIFICATION_LLM_MAX_TOKENS == 512 + assert settings.CLASSIFICATION_LLM_TOP_P == 0.9 + + def test_settings_missing_required_field_llm_provider(self): + """Test that missing LLM_PROVIDER raises ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + Settings(LLM_MODEL="gpt-4") + + errors = exc_info.value.errors() + assert any(error['loc'] == ('LLM_PROVIDER',) for error in errors) + + def test_settings_missing_required_field_llm_model(self): + """Test that missing LLM_MODEL raises ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + Settings(LLM_PROVIDER="openai") + + errors = exc_info.value.errors() + assert any(error['loc'] == ('LLM_MODEL',) for error in errors) + + def test_settings_invalid_llm_provider(self): + """Test that invalid LLM_PROVIDER raises ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + Settings( + LLM_PROVIDER="invalid_provider", + LLM_MODEL="gpt-4" + ) + + errors = exc_info.value.errors() + assert any("LLM_PROVIDER" in str(error) for error in errors) + + def test_settings_valid_providers(self): + """Test that valid providers are accepted.""" + for provider in ["openai", "anthropic"]: + settings = Settings( + LLM_PROVIDER=provider, + LLM_MODEL="test-model" + ) + assert settings.LLM_PROVIDER == provider + + def test_settings_temperature_validation(self): + """Test temperature validation.""" + # Valid temperature + settings = Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + LLM_TEMPERATURE=1.5 + ) + assert settings.LLM_TEMPERATURE == 1.5 + + # Temperature too low + with pytest.raises(ValidationError): + Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + LLM_TEMPERATURE=-0.1 + ) + + # Temperature too high + with pytest.raises(ValidationError): + Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + LLM_TEMPERATURE=2.1 + ) + + def test_settings_from_yaml_valid(self): + """Test loading settings from valid YAML file.""" + yaml_content = """ +APP_NAME: "Test Agent" +VERSION: "2.0.0" +DEBUG: true +LLM_PROVIDER: "openai" +LLM_MODEL: "gpt-3.5-turbo" +LLM_TEMPERATURE: 0.5 +HOST: "127.0.0.1" +PORT: 9000 +LOG_LEVEL: "DEBUG" +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + temp_path = f.name + + try: + settings = Settings.from_yaml(temp_path) + + assert settings.APP_NAME == "Test Agent" + assert settings.VERSION == "2.0.0" + assert settings.DEBUG is True + assert settings.LLM_PROVIDER == "openai" + assert settings.LLM_MODEL == "gpt-3.5-turbo" + assert settings.LLM_TEMPERATURE == 0.5 + assert settings.HOST == "127.0.0.1" + assert settings.PORT == 9000 + assert settings.LOG_LEVEL == "DEBUG" + finally: + os.unlink(temp_path) + + def test_settings_from_yaml_missing_required(self): + """Test that loading YAML without required fields raises error.""" + yaml_content = """ +APP_NAME: "Test Agent" +VERSION: "2.0.0" +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(yaml_content) + temp_path = f.name + + try: + with pytest.raises(ValidationError): + Settings.from_yaml(temp_path) + finally: + os.unlink(temp_path) + + def test_settings_from_yaml_file_not_found(self): + """Test that loading from non-existent file raises FileNotFoundError.""" + with pytest.raises(FileNotFoundError): + Settings.from_yaml("/nonexistent/path/config.yaml") + + def test_settings_from_yaml_empty_file(self): + """Test loading from empty YAML file.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write("") + temp_path = f.name + + try: + with pytest.raises(ValidationError): + Settings.from_yaml(temp_path) + finally: + os.unlink(temp_path) + + def test_settings_log_level_validation(self): + """Test log level validation.""" + # Valid log levels + for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: + settings = Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + LOG_LEVEL=level + ) + assert settings.LOG_LEVEL == level + + # Case insensitive + settings = Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + LOG_LEVEL="debug" + ) + assert settings.LOG_LEVEL == "DEBUG" + + # Invalid log level + with pytest.raises(ValidationError): + Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + LOG_LEVEL="INVALID" + ) + + def test_settings_log_format_validation(self): + """Test log format validation.""" + # Valid formats + for fmt in ["json", "text"]: + settings = Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + LOG_FORMAT=fmt + ) + assert settings.LOG_FORMAT == fmt + + # Case insensitive + settings = Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + LOG_FORMAT="JSON" + ) + assert settings.LOG_FORMAT == "json" + + # Invalid format + with pytest.raises(ValidationError): + Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + LOG_FORMAT="xml" + ) + + def test_settings_port_validation(self): + """Test port number validation.""" + # Valid port + settings = Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + PORT=8080 + ) + assert settings.PORT == 8080 + + # Port too low + with pytest.raises(ValidationError): + Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + PORT=0 + ) + + # Port too high + with pytest.raises(ValidationError): + Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + PORT=65536 + ) + + +class TestLLMConfig: + """Test LLMConfig model.""" + + def test_llm_config_valid(self): + """Test creating valid LLMConfig.""" + config = LLMConfig( + provider="openai", + model="gpt-4", + temperature=0.8, + max_tokens=1000 + ) + + assert config.provider == "openai" + assert config.model == "gpt-4" + assert config.temperature == 0.8 + assert config.max_tokens == 1000 + + def test_llm_config_default_temperature(self): + """Test default temperature value.""" + config = LLMConfig( + provider="openai", + model="gpt-4" + ) + + assert config.temperature == 0.7 + + def test_llm_config_invalid_provider(self): + """Test that invalid provider raises error.""" + with pytest.raises(ValidationError): + LLMConfig( + provider="invalid", + model="gpt-4" + ) + + def test_llm_config_temperature_bounds(self): + """Test temperature boundary validation.""" + # Valid boundaries + LLMConfig(provider="openai", model="gpt-4", temperature=0.0) + LLMConfig(provider="openai", model="gpt-4", temperature=2.0) + + # Invalid boundaries + with pytest.raises(ValidationError): + LLMConfig(provider="openai", model="gpt-4", temperature=-0.1) + + with pytest.raises(ValidationError): + LLMConfig(provider="openai", model="gpt-4", temperature=2.1) + + def test_llm_config_max_tokens_validation(self): + """Test max_tokens validation.""" + # Valid max_tokens + config = LLMConfig( + provider="openai", + model="gpt-4", + max_tokens=100 + ) + assert config.max_tokens == 100 + + # None is valid + config = LLMConfig( + provider="openai", + model="gpt-4", + max_tokens=None + ) + assert config.max_tokens is None + + # Zero or negative is invalid + with pytest.raises(ValidationError): + LLMConfig( + provider="openai", + model="gpt-4", + max_tokens=0 + ) + + +class TestAgentConfig: + """Test AgentConfig model.""" + + def test_agent_config_valid(self): + """Test creating valid AgentConfig.""" + config = AgentConfig( + name="Test Agent", + description="A test agent", + system_prompt="You are a test assistant", + tools=["tool1", "tool2"], + max_iterations=5 + ) + + assert config.name == "Test Agent" + assert config.description == "A test agent" + assert config.system_prompt == "You are a test assistant" + assert config.tools == ["tool1", "tool2"] + assert config.max_iterations == 5 + + def test_agent_config_default_tools(self): + """Test default empty tools list.""" + config = AgentConfig( + name="Test Agent", + description="A test agent", + system_prompt="You are a test assistant" + ) + + assert config.tools == [] + + def test_agent_config_default_max_iterations(self): + """Test default max_iterations value.""" + config = AgentConfig( + name="Test Agent", + description="A test agent", + system_prompt="You are a test assistant" + ) + + assert config.max_iterations == 10 + + def test_agent_config_max_iterations_validation(self): + """Test max_iterations must be positive.""" + # Valid + AgentConfig( + name="Test Agent", + description="A test agent", + system_prompt="You are a test assistant", + max_iterations=1 + ) + + # Invalid + with pytest.raises(ValidationError): + AgentConfig( + name="Test Agent", + description="A test agent", + system_prompt="You are a test assistant", + max_iterations=0 + ) + + +class TestSettingsConversion: + """Test Settings conversion methods.""" + + def test_to_llm_config(self): + """Test converting Settings to LLMConfig.""" + settings = Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + LLM_TEMPERATURE=0.9, + LLM_MAX_TOKENS=2000 + ) + + llm_config = settings.to_llm_config() + + assert isinstance(llm_config, LLMConfig) + assert llm_config.provider == "openai" + assert llm_config.model == "gpt-4" + assert llm_config.temperature == 0.9 + assert llm_config.max_tokens == 2000 + + def test_to_agent_config_with_all_fields(self): + """Test converting Settings to AgentConfig when all fields present.""" + settings = Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + AGENT_NAME="My Agent", + AGENT_DESCRIPTION="My agent description", + AGENT_SYSTEM_PROMPT="You are my agent", + AGENT_MAX_ITERATIONS=15 + ) + + agent_config = settings.to_agent_config() + + assert agent_config is not None + assert isinstance(agent_config, AgentConfig) + assert agent_config.name == "My Agent" + assert agent_config.description == "My agent description" + assert agent_config.system_prompt == "You are my agent" + assert agent_config.max_iterations == 15 + + def test_to_agent_config_missing_fields(self): + """Test that to_agent_config returns None when fields missing.""" + settings = Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4" + ) + + agent_config = settings.to_agent_config() + + assert agent_config is None diff --git a/tests_original_develop/unit/test_emulator_close_case_node.py b/tests_original_develop/unit/test_emulator_close_case_node.py new file mode 100644 index 0000000..2036e5f --- /dev/null +++ b/tests_original_develop/unit/test_emulator_close_case_node.py @@ -0,0 +1,272 @@ +"""Unit tests for the emulator close_case_node — focused on the Siebel SR closing +step that runs after the DB update + OCI publish.""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from langchain_core.messages import HumanMessage + +from src.agent.nodes.emulator import close_case_node +from src.api.schemas.siebel_schemas import SiebelSRStatusRequestPosPago +from src.components.clients.exceptions.siebel_exceptions import ( + SiebelClientError, + SiebelHttpError, + SiebelConnectionError, + SiebelTimeoutError, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_CASE_DATA = { + "siebel_sr_data": {"interactionProtocol": "2026000068320"}, + "complaint": {"complaintProtocol": "12345678"}, + "imdb_access_data": {"plan": {"Type": "pós-pago"}}, +} + +_SIEBEL_SUCCESS_RESPONSE = {"status": "ok"} +_HTTP_META = {"url": "https://fake", "status_code": 200, "response_text": "{}", "latency_ms": 50, "retry_count": 0} + + +def _make_state( + case_data: dict = None, + case_response: str = "Reclamação analisada e encaminhada para resolução.", + transaction_id: str = "tx-1", + dry_run: bool = False, +) -> dict: + metadata = { + "transaction_id": transaction_id, + "case_response": case_response, + "dry_run": dry_run, + } + if case_data is not None: + metadata["case_data"] = case_data + return { + "messages": [HumanMessage(content="test")], + "current_step": "validate_response", + "iteration_count": 1, + "tool_results": None, + "final_response": None, + "session_id": "test-session", + "metadata": metadata, + "error": None, + } + + +def _make_mock_siebel_client(side_effect=None): + mock = MagicMock() + if side_effect: + mock.update_service_request_status_with_retry = AsyncMock(side_effect=side_effect) + else: + mock.update_service_request_status_with_retry = AsyncMock( + return_value=(_SIEBEL_SUCCESS_RESPONSE, _HTTP_META), + ) + return mock + + +def _patch_close_case_deps(siebel_mock): + """Patches DB, OCI producer and SiebelClient in one go.""" + return ( + patch("src.agent.nodes.emulator.close_case_node._update_case_in_db", AsyncMock(return_value=None)), + patch("src.agent.nodes.emulator.close_case_node._publish_response_event", AsyncMock(return_value=None)), + patch("src.agent.nodes.emulator.close_case_node.SiebelClient", return_value=siebel_mock), + patch( + "src.agent.nodes.emulator.close_case_node.build_emulator_response_event", + return_value=MagicMock(model_dump=lambda: {"transactionId": "tx-1"}, transactionId="tx-1"), + ), + ) + + +# --------------------------------------------------------------------------- +# SiebelSRStatusRequestPosPago — build_notes e to_payload +# --------------------------------------------------------------------------- + +@pytest.mark.unit +def test_build_notes_format(): + notes = SiebelSRStatusRequestPosPago.build_notes("12345678", "Resposta aceita pelo consultor.") + assert "12345678" in notes + assert "Resposta aceita pelo consultor." in notes + assert notes.startswith("Atendimento finalizado para a reclamação Anatel") + + +@pytest.mark.unit +def test_to_payload_structure(): + req = SiebelSRStatusRequestPosPago( + protocol_number="2026000068320", + notes="Nota de teste.", + date="05/19/2026 17:31:50", + ) + payload = req.to_payload() + assert payload["channel"] == "Anatel" + assert payload["serviceRequest"]["protocolNumber"] == "2026000068320" + assert payload["serviceRequest"]["status"] == "Fechado" + assert payload["serviceRequest"]["notes"] == "Nota de teste." + assert payload["date"] == "05/19/2026 17:31:50" + + +# --------------------------------------------------------------------------- +# Happy path — full close_case (DB + OCI + Siebel) +# --------------------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_success_full_flow_marks_case_closed(): + mock_client = _make_mock_siebel_client() + patches = _patch_close_case_deps(mock_client) + with patches[0], patches[1], patches[2], patches[3]: + result = await close_case_node.close_case(_make_state(case_data=_CASE_DATA)) + + assert result["current_step"] == "case_closed" + assert result["error"] is None + assert result["metadata"]["siebel_sr_closing_data"] == _SIEBEL_SUCCESS_RESPONSE + assert result["metadata"]["siebel_sr_closing_error"] is None + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_success_payload_carries_protocol_and_notes(): + mock_client = _make_mock_siebel_client() + patches = _patch_close_case_deps(mock_client) + with patches[0], patches[1], patches[2], patches[3]: + await close_case_node.close_case(_make_state(case_data=_CASE_DATA)) + + call = mock_client.update_service_request_status_with_retry.call_args + payload = call.kwargs.get("payload") or call.args[0] + assert payload["serviceRequest"]["protocolNumber"] == "2026000068320" + assert payload["serviceRequest"]["status"] == "Fechado" + assert "12345678" in payload["serviceRequest"]["notes"] + assert "Reclamação analisada" in payload["serviceRequest"]["notes"] + assert call.kwargs.get("plan_type") == "pós-pago" + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_express_plan_routes_to_prepago(): + case_data = { + **_CASE_DATA, + "imdb_access_data": {"plan": {"Type": "Express"}}, + "customer": {"msisdn": "11999999999"}, + } + mock_client = _make_mock_siebel_client() + patches = _patch_close_case_deps(mock_client) + with patches[0], patches[1], patches[2], patches[3]: + await close_case_node.close_case(_make_state(case_data=case_data)) + + call = mock_client.update_service_request_status_with_retry.call_args + assert call.kwargs.get("plan_type") == "Express" + payload = call.kwargs.get("payload") or call.args[0] + assert payload["msisdn"] == "11999999999" + assert payload["status"] == "Closed" + + +# --------------------------------------------------------------------------- +# Dry run skips Siebel +# --------------------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_dry_run_skips_siebel(): + mock_client = _make_mock_siebel_client() + patches = _patch_close_case_deps(mock_client) + with patches[0], patches[1], patches[2], patches[3]: + result = await close_case_node.close_case( + _make_state(case_data=_CASE_DATA, dry_run=True), + ) + + mock_client.update_service_request_status_with_retry.assert_not_awaited() + assert result["current_step"] == "case_closed" + assert result["error"] is None + + +# --------------------------------------------------------------------------- +# Validation errors on Siebel payload — non-blocking +# --------------------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_missing_protocol_records_error_but_closes_case(): + case_data = {**_CASE_DATA, "siebel_sr_data": {}} + mock_client = _make_mock_siebel_client() + patches = _patch_close_case_deps(mock_client) + with patches[0], patches[1], patches[2], patches[3]: + result = await close_case_node.close_case(_make_state(case_data=case_data)) + + mock_client.update_service_request_status_with_retry.assert_not_awaited() + assert result["current_step"] == "case_closed" + assert result["error"] is None + assert result["metadata"]["siebel_sr_closing_error"]["type"] == "ValidationError" + assert "interactionProtocol" in result["metadata"]["siebel_sr_closing_error"]["message"] + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_missing_complaint_protocol_records_error(): + case_data = {**_CASE_DATA, "complaint": {}} + mock_client = _make_mock_siebel_client() + patches = _patch_close_case_deps(mock_client) + with patches[0], patches[1], patches[2], patches[3]: + result = await close_case_node.close_case(_make_state(case_data=case_data)) + + assert result["current_step"] == "case_closed" + assert result["metadata"]["siebel_sr_closing_error"]["type"] == "ValidationError" + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_prepago_missing_msisdn_records_error(): + case_data = {**_CASE_DATA, "imdb_access_data": {"plan": {"Type": "Pré-Pago"}}} + mock_client = _make_mock_siebel_client() + patches = _patch_close_case_deps(mock_client) + with patches[0], patches[1], patches[2], patches[3]: + result = await close_case_node.close_case(_make_state(case_data=case_data)) + + assert result["metadata"]["siebel_sr_closing_error"]["type"] == "ValidationError" + assert "msisdn" in result["metadata"]["siebel_sr_closing_error"]["message"] + + +# --------------------------------------------------------------------------- +# Siebel client errors — non-blocking (case still closed) +# --------------------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize("exception, expected_type", [ + (SiebelHttpError(500, "Internal Server Error"), "SiebelHttpError"), + (SiebelConnectionError("Connection refused"), "SiebelConnectionError"), + (SiebelTimeoutError("Request timed out"), "SiebelTimeoutError"), + (SiebelClientError("Unexpected error"), "SiebelClientError"), +]) +async def test_client_errors_recorded_but_case_closes(exception, expected_type): + mock_client = _make_mock_siebel_client(side_effect=exception) + patches = _patch_close_case_deps(mock_client) + with patches[0], patches[1], patches[2], patches[3]: + result = await close_case_node.close_case(_make_state(case_data=_CASE_DATA)) + + assert result["current_step"] == "case_closed" + assert result["error"] is None + assert result["metadata"]["siebel_sr_closing_error"]["type"] == expected_type + assert result["metadata"]["siebel_sr_closing_data"] is None + + +# --------------------------------------------------------------------------- +# Pre-Siebel errors — still bloqueiam (DB/OCI são críticos) +# --------------------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_missing_transaction_id_returns_error(): + state = _make_state(case_data=_CASE_DATA) + state["metadata"]["transaction_id"] = None + result = await close_case_node.close_case(state) + assert result["error"]["type"] == "CloseCaseError" + assert result["current_step"] == "close_case_failed" + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_missing_case_response_returns_error(): + state = _make_state(case_data=_CASE_DATA, case_response="") + result = await close_case_node.close_case(state) + assert result["error"]["type"] == "CloseCaseError" + assert result["current_step"] == "close_case_failed" diff --git a/tests_original_develop/unit/test_identity_verification.py b/tests_original_develop/unit/test_identity_verification.py new file mode 100644 index 0000000..42c320a --- /dev/null +++ b/tests_original_develop/unit/test_identity_verification.py @@ -0,0 +1,352 @@ +"""Unit tests for identity_verification_node.""" + +import pytest + +from src.agent.nodes.identity_verification_node import ( + IDENTITY_DECISION_CANCEL, + IDENTITY_DECISION_PROCEED, + IDENTITY_DECISION_SMART_HUMAN, + _authorized_via_attachment, + _authorized_via_gov_br_seal, + _is_third_party_complaint, + _normalize_cpf, + perform_identity_verification, + route_after_identity_verification, +) +from src.utils.decision_helpers import CANCEL_REASON_UNAUTHORIZED_THIRD_PARTY + +CANCELATION_REASON_1 = "Processo Interno" +CANCELATION_REASON_2 = "Atendimento" +CANCELATION_REASON_3 = "Cancelamento ID Anatel" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +TITULAR_CPF = "12345678901" +TERCEIRO_CPF = "99988877766" +ASSINANTE_CPF = "11122233344" + + +def _make_state( + customer_cpf=TITULAR_CPF, + subscriber_cpf=TITULAR_CPF, + imdb_cpf=TITULAR_CPF, + imdb_status=200, + gov_br_seal=None, + has_attachment=None, + omit_context=False, +): + """Build a minimal AgentState driving the identity_verification node.""" + customer = { + "cpfCnpj": customer_cpf, + "subscriber": {"cpfCnpj": subscriber_cpf} if subscriber_cpf is not None else {}, + } + if gov_br_seal is not None: + customer["govBrSeal"] = gov_br_seal + if has_attachment is not None: + customer["hasAttachment"] = has_attachment + + request_context = { + "customer": customer, + # `cpf_cnpj` continua sendo o eco do customer.cpfCnpj (consumido pelo + # cache_check para invalidação). A comparação real do titular usa + # `socialSecNo` (campo retornado pela IMDB). + "imdb_access_data": { + "status_code": imdb_status, + "cpf_cnpj": customer_cpf, + "socialSecNo": imdb_cpf, + }, + } + + metadata = {} if omit_context else {"request_context": request_context} + return { + "messages": [], + "current_step": "imdb_enrichment_completed", + "iteration_count": 0, + "tool_results": None, + "final_response": None, + "session_id": "sess-idv-test", + "metadata": metadata, + "error": None, + } + + +def _idv(state): + """Shortcut: read identity_verification snapshot from state.""" + return state["metadata"]["request_context"]["identity_verification"] + + +# --------------------------------------------------------------------------- +# 1. Tabela de decisão — os 4 cenários principais +# --------------------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.asyncio +class TestDecisionTable: + + async def test_titular_proceeds(self): + """Customer == Subscriber == IMDB → proceed (fluxo normal).""" + state = await perform_identity_verification(_make_state()) + + snap = _idv(state) + assert snap["decision"] == IDENTITY_DECISION_PROCEED + assert snap["third_party_complaint"] is False + assert snap["divergence_source"] is None + assert state["current_step"] == "identity_verification" + # PROCEED não deve ativar smart human nem cancelamento + ctx = state["metadata"]["request_context"] + assert "smart_human_handling" not in ctx + assert ctx.get("siebel_action") != "cancelar" + + async def test_terceiro_com_gov_br_vai_para_smart_human(self): + """Customer ≠ Subscriber + Selo GOV BR autenticado → smart_human.""" + state = await perform_identity_verification(_make_state( + customer_cpf=TERCEIRO_CPF, + subscriber_cpf=ASSINANTE_CPF, + imdb_cpf=ASSINANTE_CPF, + gov_br_seal=True, + )) + + snap = _idv(state) + assert snap["decision"] == IDENTITY_DECISION_SMART_HUMAN + assert snap["third_party_complaint"] is True + assert snap["divergence_source"] == "subscriber" + assert snap["authorized_via_gov_br_seal"] is True + + ctx = state["metadata"]["request_context"] + assert ctx["smart_human_handling"] is True + assert "Selo GOV BR" in ctx["smart_human_reason"] + assert ctx.get("siebel_action") != "cancelar" + + async def test_terceiro_com_anexo_vai_para_smart_human(self): + """Customer ≠ Subscriber + sem selo + anexo presente → smart_human.""" + state = await perform_identity_verification(_make_state( + customer_cpf=TERCEIRO_CPF, + subscriber_cpf=ASSINANTE_CPF, + imdb_cpf=ASSINANTE_CPF, + gov_br_seal=False, + has_attachment=True, + )) + + snap = _idv(state) + assert snap["decision"] == IDENTITY_DECISION_SMART_HUMAN + assert snap["third_party_complaint"] is True + assert snap["authorized_via_gov_br_seal"] is False + assert snap["authorized_via_attachment"] is True + + ctx = state["metadata"]["request_context"] + assert ctx["smart_human_handling"] is True + assert "anexo" in ctx["smart_human_reason"].lower() + + async def test_terceiro_nao_autorizado_e_cancelado(self): + """Customer ≠ Subscriber + sem selo + sem anexo → cancel + triplet Siebel.""" + state = await perform_identity_verification(_make_state( + customer_cpf=TERCEIRO_CPF, + subscriber_cpf=ASSINANTE_CPF, + imdb_cpf=ASSINANTE_CPF, + gov_br_seal=False, + has_attachment=False, + )) + + snap = _idv(state) + assert snap["decision"] == IDENTITY_DECISION_CANCEL + assert snap["third_party_complaint"] is True + assert snap["authorized_via_gov_br_seal"] is False + assert snap["authorized_via_attachment"] is False + + ctx = state["metadata"]["request_context"] + assert ctx["siebel_action"] == "cancelar" + assert ctx["reason1"] == CANCELATION_REASON_1 + assert ctx["reason2"] == CANCELATION_REASON_2 + assert ctx["reason3"] == CANCELATION_REASON_3 + assert ctx["cancel_reason"] == CANCEL_REASON_UNAUTHORIZED_THIRD_PARTY + assert ctx["canceling_reasoning"] == snap["reasoning"] + # identity_verification cancela inline e move o step para o ramo de cancelamento + assert state["current_step"] == "canceling_analysis_cancel_ticket" + + +# --------------------------------------------------------------------------- +# 2. Detecção de "reclamação por terceiro" +# --------------------------------------------------------------------------- + +@pytest.mark.unit +class TestThirdPartyDetection: + + def test_subscriber_divergence_takes_priority(self): + customer = {"cpfCnpj": TERCEIRO_CPF, "subscriber": {"cpfCnpj": ASSINANTE_CPF}} + imdb = {"status_code": 200, "socialSecNo": TITULAR_CPF} # divergente também, mas nem é alcançado + third, src = _is_third_party_complaint(customer, imdb) + assert third is True + assert src == "subscriber" + + def test_imdb_divergence_when_subscriber_matches(self): + customer = {"cpfCnpj": TITULAR_CPF, "subscriber": {"cpfCnpj": TITULAR_CPF}} + imdb = {"status_code": 200, "socialSecNo": TERCEIRO_CPF} + third, src = _is_third_party_complaint(customer, imdb) + assert third is True + assert src == "imdb" + + def test_imdb_cpf_echo_field_is_not_used_for_comparison(self): + """`cpf_cnpj` é eco do customer e não deve disparar divergência.""" + customer = {"cpfCnpj": TITULAR_CPF, "subscriber": {"cpfCnpj": TITULAR_CPF}} + imdb = {"status_code": 200, "cpf_cnpj": TERCEIRO_CPF, "socialSecNo": TITULAR_CPF} + third, _ = _is_third_party_complaint(customer, imdb) + assert third is False + + def test_imdb_204_is_ignored(self): + """IMDB 204 (no content) — comparison must be skipped per the rule.""" + customer = {"cpfCnpj": TITULAR_CPF, "subscriber": {"cpfCnpj": TITULAR_CPF}} + imdb = {"status_code": 204, "socialSecNo": TERCEIRO_CPF} + third, src = _is_third_party_complaint(customer, imdb) + assert third is False + assert src is None + + def test_imdb_200_without_cpf_is_ignored(self): + """IMDB respondeu 200 mas sem socialSecNo → não comparar.""" + customer = {"cpfCnpj": TITULAR_CPF, "subscriber": {"cpfCnpj": TITULAR_CPF}} + imdb = {"status_code": 200, "socialSecNo": None} + third, _ = _is_third_party_complaint(customer, imdb) + assert third is False + + def test_non_200_status_is_ignored(self): + """Qualquer status != 200 (timeout, 5xx) → comparação IMDB silenciada.""" + customer = {"cpfCnpj": TITULAR_CPF, "subscriber": {"cpfCnpj": TITULAR_CPF}} + imdb = {"status_code": 500, "socialSecNo": TERCEIRO_CPF} + third, _ = _is_third_party_complaint(customer, imdb) + assert third is False + + def test_formatted_cpfs_are_normalized(self): + """CPFs com pontos/traços devem ser comparados pelos dígitos.""" + customer = { + "cpfCnpj": "123.456.789-01", + "subscriber": {"cpfCnpj": "12345678901"}, + } + imdb = {"status_code": 200, "socialSecNo": "123-456-789/01"} + third, _ = _is_third_party_complaint(customer, imdb) + assert third is False + + def test_missing_customer_cpf_skips_comparison(self): + customer = {"cpfCnpj": None, "subscriber": {"cpfCnpj": TITULAR_CPF}} + imdb = {"status_code": 200, "socialSecNo": TERCEIRO_CPF} + third, _ = _is_third_party_complaint(customer, imdb) + assert third is False + + +# --------------------------------------------------------------------------- +# 3. Selo GOV BR e Anexo +# --------------------------------------------------------------------------- + +@pytest.mark.unit +class TestGovBrSeal: + + @pytest.mark.parametrize("value, expected", [ + (True, True), + (False, False), + (None, False), + ("", False), + (" ", False), # whitespace só + ("autenticado", True), + ]) + def test_seal_variants(self, value, expected): + assert _authorized_via_gov_br_seal({"govBrSeal": value}) is expected + + def test_seal_missing_key(self): + assert _authorized_via_gov_br_seal({}) is False + + +@pytest.mark.unit +class TestAttachmentAuth: + + @pytest.mark.parametrize("value, expected", [ + (True, True), + (False, False), + (None, False), + ("true", False), # apenas bool literal True conta + (1, False), + ]) + def test_attachment_variants(self, value, expected): + assert _authorized_via_attachment({"hasAttachment": value}) is expected + + def test_attachment_missing_key(self): + assert _authorized_via_attachment({}) is False + + +# --------------------------------------------------------------------------- +# 4. Normalização de CPF +# --------------------------------------------------------------------------- + +@pytest.mark.unit +class TestNormalizeCpf: + + @pytest.mark.parametrize("raw, expected", [ + ("12345678901", "12345678901"), + ("123.456.789-01", "12345678901"), + (" 123 456 789 01 ", "12345678901"), + ("", None), + ("--..", None), + (None, None), + ]) + def test_normalize(self, raw, expected): + assert _normalize_cpf(raw) == expected + + +# --------------------------------------------------------------------------- +# 5. Erros e contratos do node +# --------------------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.asyncio +class TestNodeContract: + + async def test_missing_request_context_sets_validation_error(self): + state = await perform_identity_verification(_make_state(omit_context=True)) + assert state["error"]["type"] == "ValidationError" + assert state["error"]["step"] == "identity_verification" + + async def test_state_snapshot_always_populated(self): + """Toda decisão bem-sucedida grava um snapshot completo.""" + state = await perform_identity_verification(_make_state( + customer_cpf=TERCEIRO_CPF, + subscriber_cpf=ASSINANTE_CPF, + gov_br_seal=True, + )) + snap = _idv(state) + for key in ( + "decision", + "third_party_complaint", + "divergence_source", + "authorized_via_gov_br_seal", + "authorized_via_attachment", + "reasoning", + ): + assert key in snap + + +# --------------------------------------------------------------------------- +# 6. Router (mapeamento decisão → próximo node) +# --------------------------------------------------------------------------- + +@pytest.mark.unit +class TestRouter: + + @pytest.mark.parametrize("decision, expected_route", [ + (IDENTITY_DECISION_PROCEED, "proceed"), + (IDENTITY_DECISION_SMART_HUMAN, "smart_human"), + (IDENTITY_DECISION_CANCEL, "cancel"), + ("anything_else", "failed"), + (None, "failed"), + ]) + def test_routing(self, decision, expected_route): + state = { + "metadata": { + "request_context": {"identity_verification": {"decision": decision}} + } + } + assert route_after_identity_verification(state) == expected_route + + def test_routing_missing_identity_section(self): + """Sem snapshot de identity_verification → 'failed'.""" + state = {"metadata": {"request_context": {}}} + assert route_after_identity_verification(state) == "failed" diff --git a/tests_original_develop/unit/test_imdb_client.py b/tests_original_develop/unit/test_imdb_client.py new file mode 100644 index 0000000..f7f13a8 --- /dev/null +++ b/tests_original_develop/unit/test_imdb_client.py @@ -0,0 +1,288 @@ +import json +import pytest +import httpx +from unittest.mock import patch, MagicMock, AsyncMock +from src.components.clients.imdb_client import ImdbClient +from src.components.clients.exceptions.imdb_exceptions import ( + ImdbClientError, + ImdbHttpError, + ImdbTimeoutError, + ImdbConnectionError, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def client(): + return ImdbClient() + + +def _make_mock_response(status_code: int, json_data: dict = None, content: bytes = b"data", headers: dict = None): + """Helper to create a mock httpx.Response.""" + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = status_code + mock_resp.content = content + mock_resp.headers = headers or {} + if json_data is not None: + mock_resp.json.return_value = json_data + return mock_resp + + +# --------------------------------------------------------------------------- +# Unit tests — helper methods +# --------------------------------------------------------------------------- + +@pytest.mark.unit +class TestBuildHeaders: + + def test_build_headers_without_message_id(self, client): + headers = client._build_headers(client_id="test-client") + assert headers["clientId"] == "test-client" + assert "Authorization" in headers + assert headers["Accept"] == "application/json" + assert "messageId" not in headers + + def test_build_headers_with_client_id(self, client): + headers = client._build_headers(client_id="test-client") + assert headers["clientId"] == "test-client" + assert "Authorization" in headers + + +@pytest.mark.unit +class TestBuildUrl: + + def test_build_url_appends_msisdn(self, client): + msisdn = "5511999999999" + url = client._build_url(msisdn) + assert url.endswith(msisdn) + assert msisdn in url + + +@pytest.mark.unit +class TestParseResponse: + + def test_parse_response_valid_json(self, client): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "plan": {"name": "TIM Black"}, + "statusType": "Active", + "statusDescription": "Full Access", + "socialSecNo": "06252533106", + "extraField": "ignored", + } + result = client._parse_response(mock_resp) + assert result.plan == {"name": "TIM Black"} + assert result.statusType == "Active" + assert result.statusDescription == "Full Access" + assert result.socialSecNo == "06252533106" + + def test_parse_response_missing_optional_keys(self, client): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "plan": None, + "statusType": None, + "statusDescription": None + } + result = client._parse_response(mock_resp) + assert result.plan is None + assert result.statusType is None + assert result.statusDescription is None + assert result.socialSecNo is None + + def test_parse_response_invalid_json_raises_imdb_client_error(self, client): + mock_resp = MagicMock() + mock_resp.json.side_effect = json.JSONDecodeError("error", "", 0) + with pytest.raises(ImdbClientError, match="Invalid JSON response from IMDB API"): + client._parse_response(mock_resp) + + +# --------------------------------------------------------------------------- +# Unit tests — get_imdb_access_data (async) +# --------------------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.asyncio +class TestGetImdbAccessData: + + @patch("src.components.clients.imdb_client.traced_async_client") + async def test_success_returns_parsed_data(self, mock_async_client_cls, client): + # Arrange + json_data = {"plan": {"name": "TIM Black"}, "statusType": "Active", "statusDescription": "Full Access"} + mock_resp = _make_mock_response(200, json_data=json_data, headers={"Messageid": "msg-123"}) + mock_resp.raise_for_status = MagicMock() + + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + mock_ctx.get = AsyncMock(return_value=mock_resp) + mock_async_client_cls.return_value = mock_ctx + + # Act + result = await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc") + + # Assert + assert result.plan == {"name": "TIM Black"} + assert result.statusType == "Active" + assert result.statusDescription == "Full Access" + + @patch("src.components.clients.imdb_client.traced_async_client") + async def test_204_no_content_returns_none(self, mock_async_client_cls, client): + # Arrange + mock_resp = _make_mock_response(204, content=b"") + mock_resp.content = b"" + + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + mock_ctx.get = AsyncMock(return_value=mock_resp) + mock_async_client_cls.return_value = mock_ctx + + # Act + result = await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc") + + # Assert + assert result is None + + @patch("src.components.clients.imdb_client.traced_async_client") + async def test_http_404_raises_imdb_http_error(self, mock_async_client_cls, client): + # Arrange + mock_resp = _make_mock_response(404) + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "Not Found", request=MagicMock(), response=mock_resp + ) + + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + mock_ctx.get = AsyncMock(return_value=mock_resp) + mock_async_client_cls.return_value = mock_ctx + + # Act & Assert + with pytest.raises(ImdbHttpError) as exc_info: + await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc") + assert exc_info.value.status_code == 404 + + @patch("src.components.clients.imdb_client.traced_async_client") + async def test_http_500_raises_imdb_http_error(self, mock_async_client_cls, client): + # Arrange + mock_resp = _make_mock_response(500) + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "Internal Server Error", request=MagicMock(), response=mock_resp + ) + + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + mock_ctx.get = AsyncMock(return_value=mock_resp) + mock_async_client_cls.return_value = mock_ctx + + # Act & Assert + with pytest.raises(ImdbHttpError) as exc_info: + await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc") + assert exc_info.value.status_code == 500 + + @patch("src.components.clients.imdb_client.traced_async_client") + async def test_timeout_raises_imdb_timeout_error(self, mock_async_client_cls, client): + # Arrange + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + mock_ctx.get = AsyncMock(side_effect=httpx.TimeoutException("timed out")) + mock_async_client_cls.return_value = mock_ctx + + # Act & Assert + with pytest.raises(ImdbTimeoutError): + await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc") + + @patch("src.components.clients.imdb_client.traced_async_client") + async def test_request_error_raises_imdb_connection_error(self, mock_async_client_cls, client): + # Arrange + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + mock_ctx.get = AsyncMock(side_effect=httpx.RequestError("connection refused")) + mock_async_client_cls.return_value = mock_ctx + + # Act & Assert + with pytest.raises(ImdbConnectionError): + await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc") + + @patch("src.components.clients.imdb_client.traced_async_client") + async def test_unexpected_exception_raises_imdb_client_error(self, mock_async_client_cls, client): + # Arrange + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + mock_ctx.get = AsyncMock(side_effect=RuntimeError("unexpected")) + mock_async_client_cls.return_value = mock_ctx + + # Act & Assert + with pytest.raises(ImdbClientError): + await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc") + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestImdbClientRetry: + + @patch("src.components.clients.imdb_client.ImdbClient.get_imdb_access_data") + async def test_retry_on_connection_error_success_second_attempt(self, mock_get_data, client): + # Arrange + mock_get_data.side_effect = [ + ImdbConnectionError("http://test", "timeout"), + MagicMock(plan={"name": "TIM Black"}, statusType="Active", statusDescription="Full Access") + ] + + # Act + with patch("src.components.clients.imdb_client.asyncio.sleep", AsyncMock()): + result = await client.get_imdb_access_data_with_retry(msisdn="5511999999999", client_id="abc") + + # Assert + assert result.plan == {"name": "TIM Black"} + assert mock_get_data.call_count == 2 + + @patch("src.components.clients.imdb_client.ImdbClient.get_imdb_access_data") + async def test_retry_on_429_success_second_attempt(self, mock_get_data, client): + # Arrange + mock_get_data.side_effect = [ + ImdbHttpError(429, "Too Many Requests"), + MagicMock(plan={"name": "TIM Black"}, statusType="Active", statusDescription="Full Access") + ] + + # Act + with patch("src.components.clients.imdb_client.asyncio.sleep", AsyncMock()): + result = await client.get_imdb_access_data_with_retry(msisdn="5511999999999", client_id="abc") + + # Assert + assert result.plan == {"name": "TIM Black"} + assert mock_get_data.call_count == 2 + + @patch("src.components.clients.imdb_client.ImdbClient.get_imdb_access_data") + async def test_no_retry_on_401_error(self, mock_get_data, client): + # Arrange + mock_get_data.side_effect = ImdbHttpError(401, "Unauthorized") + + # Act & Assert + with pytest.raises(ImdbHttpError) as exc_info: + await client.get_imdb_access_data_with_retry(msisdn="5511999999999", client_id="abc") + + assert exc_info.value.status_code == 401 + assert mock_get_data.call_count == 1 + + @patch("src.components.clients.imdb_client.ImdbClient.get_imdb_access_data") + async def test_exhaust_retries_raises_exceeded_error(self, mock_get_data, client): + # Arrange + last_error = ImdbTimeoutError("http://test", 5.0) + mock_get_data.side_effect = last_error + + # Act & Assert + from src.components.clients.exceptions.imdb_exceptions import ImdbExceededRetriesError + with patch("src.components.clients.imdb_client.asyncio.sleep", AsyncMock()): + with pytest.raises(ImdbExceededRetriesError) as exc_info: + await client.get_imdb_access_data_with_retry(msisdn="5511999999999", client_id="abc", max_retries=2) + + assert exc_info.value.attempts == 2 + assert mock_get_data.call_count == 3 # Initial + 2 retries diff --git a/tests_original_develop/unit/test_imdb_enrichment.py b/tests_original_develop/unit/test_imdb_enrichment.py new file mode 100644 index 0000000..da3842b --- /dev/null +++ b/tests_original_develop/unit/test_imdb_enrichment.py @@ -0,0 +1,187 @@ +import pytest +from unittest.mock import patch, AsyncMock +from src.agent.nodes.imdb_enrichment_node import imdb_enrich_ticket, should_continue +from src.components.clients.exceptions.imdb_exceptions import ( + ImdbClientError, + ImdbHttpError, + ImdbTimeoutError, + ImdbConnectionError, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_state(request_context=None, session_id="sess-123"): + """Build a minimal AgentState for tests.""" + return { + "messages": [], + "current_step": "start", + "iteration_count": 0, + "tool_results": None, + "final_response": None, + "session_id": session_id, + "metadata": {"request_context": request_context} if request_context else {}, + "error": None, + } + + +def _valid_context(cpf_cnpj="12345678901", msisdn="5511999999999"): + return { + "customer": { + "cpfCnpj": cpf_cnpj, + "msisdn": msisdn, + } + } + + +MOCK_IMDB_RESPONSE = { + "plan": "TIM Black", + "statusType": "Active", + "statusDescription": "Acesso Full", + "socialSecNo": "06252533106", +} + +IMDB_CLIENT_PATH = "src.agent.nodes.imdb_enrichment_node.ImdbClient" + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.asyncio +class TestImdbEnrichmentNode: + + # --- Happy path --------------------------------------------------------- + + @patch(IMDB_CLIENT_PATH) + async def test_imdb_enrichment_node_success(self, mock_client_cls): + """Node should store imdb_access_data in context and set step to 'imdb_enrichment_completed'.""" + # Arrange + mock_client = AsyncMock() + mock_client.get_imdb_access_data_with_retry.return_value = AsyncMock(model_dump=lambda: MOCK_IMDB_RESPONSE) + mock_client_cls.return_value = mock_client + + state = make_state(request_context=_valid_context()) + + # Act + final_state = await imdb_enrich_ticket(state) + + # Assert + assert final_state["current_step"] == "imdb_enrichment_completed" + assert final_state["error"] is None + + imdb_data = final_state["metadata"]["request_context"]["imdb_access_data"] + assert imdb_data["plan"] == "TIM Black" + assert imdb_data["statusType"] == "Active" + assert imdb_data["cpf_cnpj"] == "12345678901" + assert imdb_data["socialSecNo"] == "06252533106" + mock_client.get_imdb_access_data_with_retry.assert_called_once() + + # --- Validation errors -------------------------------------------------- + + async def test_imdb_enrichment_node_missing_context(self): + """No request_context → ValidationError.""" + state = make_state(request_context=None) + final_state = await imdb_enrich_ticket(state) + assert final_state["error"]["type"] == "ValidationError" + assert final_state["error"]["step"] == "imdb_enrichment_failed" + + async def test_imdb_enrichment_node_missing_msisdn(self): + """msisdn=None → Redirect (smart human handling).""" + state = make_state(request_context=_valid_context(msisdn=None)) + final_state = await imdb_enrich_ticket(state) + assert final_state["current_step"] == "imdb_enrichment_completed" + assert final_state["metadata"]["request_context"]["smart_human_handling"] is True + + # --- Client errors ------------------------------------------------------ + + @patch(IMDB_CLIENT_PATH) + async def test_imdb_enrichment_node_http_error(self, mock_client_cls): + """ImdbHttpError → error.type == 'ImdbHttpError', step == 'imdb_enrichment_failed'.""" + mock_client = AsyncMock() + mock_client.get_imdb_access_data_with_retry.side_effect = ImdbHttpError(404, "Not Found") + mock_client_cls.return_value = mock_client + + final_state = await imdb_enrich_ticket(make_state(request_context=_valid_context())) + + assert final_state["error"]["type"] == "ImdbHttpError" + assert final_state["current_step"] == "imdb_enrichment_failed" + + @patch(IMDB_CLIENT_PATH) + async def test_imdb_enrichment_node_timeout_error(self, mock_client_cls): + """ImdbTimeoutError → error.type == 'ImdbTimeoutError'.""" + mock_client = AsyncMock() + mock_client.get_imdb_access_data_with_retry.side_effect = ImdbTimeoutError("url", 5.0) + mock_client_cls.return_value = mock_client + + final_state = await imdb_enrich_ticket(make_state(request_context=_valid_context())) + + assert final_state["error"]["type"] == "ImdbTimeoutError" + assert final_state["current_step"] == "imdb_enrichment_failed" + + @patch(IMDB_CLIENT_PATH) + async def test_imdb_enrichment_node_connection_error(self, mock_client_cls): + """ImdbConnectionError → error.type == 'ImdbConnectionError'.""" + mock_client = AsyncMock() + mock_client.get_imdb_access_data_with_retry.side_effect = ImdbConnectionError("url", "Connection refused") + mock_client_cls.return_value = mock_client + + final_state = await imdb_enrich_ticket(make_state(request_context=_valid_context())) + + assert final_state["error"]["type"] == "ImdbConnectionError" + assert final_state["current_step"] == "imdb_enrichment_failed" + + @patch(IMDB_CLIENT_PATH) + async def test_imdb_enrichment_node_no_content_response(self, mock_client_cls): + """204 No Content (None response) → step == 'imdb_enrichment_completed'.""" + mock_client = AsyncMock() + mock_client.get_imdb_access_data_with_retry.return_value = None + mock_client_cls.return_value = mock_client + + final_state = await imdb_enrich_ticket(make_state(request_context=_valid_context())) + + assert final_state["current_step"] == "imdb_enrichment_completed" + assert final_state["error"] is None + + context = final_state["metadata"]["request_context"] + assert "imdb_access_data" in context + assert context["imdb_access_data"]["plan"] is None + assert context["imdb_access_data"]["status_code"] == 204 + + @patch(IMDB_CLIENT_PATH) + async def test_imdb_enrichment_node_generic_client_error(self, mock_client_cls): + """Generic ImdbClientError → error.type == 'ImdbClientError'.""" + mock_client = AsyncMock() + mock_client.get_imdb_access_data_with_retry.side_effect = ImdbClientError("Unexpected error") + mock_client_cls.return_value = mock_client + + final_state = await imdb_enrich_ticket(make_state(request_context=_valid_context())) + + assert final_state["error"]["type"] == "ImdbClientError" + assert final_state["current_step"] == "imdb_enrichment_failed" + + +# --------------------------------------------------------------------------- +# Router tests +# --------------------------------------------------------------------------- + +@pytest.mark.unit +class TestShouldContinue: + + def test_should_continue_when_imdb_enrichment_completed(self): + state = make_state() + state["current_step"] = "imdb_enrichment_completed" + assert should_continue(state) == "continue" + + def test_should_end_when_imdb_enrichment_failed(self): + state = make_state() + state["current_step"] = "imdb_enrichment_failed" + assert should_continue(state) == "failed" + + def test_should_end_on_unknown_step(self): + state = make_state() + state["current_step"] = "some_other_step" + assert should_continue(state) == "failed" diff --git a/tests_original_develop/unit/test_knowledge_base_enrichment.py b/tests_original_develop/unit/test_knowledge_base_enrichment.py new file mode 100644 index 0000000..18b091e --- /dev/null +++ b/tests_original_develop/unit/test_knowledge_base_enrichment.py @@ -0,0 +1,238 @@ +import pytest +from unittest.mock import patch, AsyncMock + +from src.agent.nodes.knowledge_base_enrichment_node import ( + enrich_with_knowledge_base, + _build_query_payload, + _extract_plan_info, +) +from src.components.clients.exceptions.tais_kb_exceptions import TaisKbClientError + + +TAIS_CLIENT_PATH = "src.agent.nodes.knowledge_base_enrichment_node.TaisKbClient" + + +def make_state(request_context=None, session_id="sess-kb-1"): + return { + "messages": [], + "current_step": "start", + "iteration_count": 0, + "tool_results": None, + "final_response": None, + "session_id": session_id, + "metadata": {"request_context": request_context} if request_context else {"request_context": {}}, + "error": None, + } + + +def _valid_context(): + return { + "speech_analytics": { + "reclamacao_resumo": "Cliente reclama de cobrança indevida", + "motivo_reclamacao": "Cobrança", + "submotivo_reclamacao": "Fatura", + "causa_raiz": "Erro de cadastro", + }, + "imdb_access_data": { + "plan": {"name": "TIM Pré Top", "Type": "Pré"}, + }, + "complaint": { + "inputChannel": "anatel", + }, + } + + +MOCK_DB_ROWS = [ + { + "title_proc": "Procedimento Cobrança Pré-Pago", + "grp_content_chunks": "## Descrição - tratativa para cobrança", + "distance": 0.21, + }, + { + "title_proc": "Procedimento Cobrança Pré-Pago", # duplicate, must be deduped + "grp_content_chunks": "outro chunk do mesmo doc", + "distance": 0.25, + }, + { + "title_proc": "Procedimento Recarga", + "grp_content_chunks": "## Descrição - recarga", + "distance": 0.30, + }, + { + "title_proc": "Procedimento Limite", + "grp_content_chunks": "## Descrição - limite", + "distance": 0.35, + }, +] + + +@pytest.mark.unit +class TestExtractPlanInfo: + def test_plan_dict_with_name_and_type(self): + plan_text, plan_type, commercial_name = _extract_plan_info({"plan": {"name": "TIM Pré Top", "Type": "Pré"}}) + assert plan_text == "TIM Pré Top" + assert plan_type == "Pré" + assert commercial_name == "TIM Pré Top" + + def test_plan_string(self): + plan_text, plan_type, commercial_name = _extract_plan_info({"plan": "Controle"}) + assert plan_text == "Controle" + assert plan_type == "Controle" + assert commercial_name is None + + def test_plan_missing(self): + assert _extract_plan_info({}) == (None, None, None) + assert _extract_plan_info({"plan": None}) == (None, None, None) + + +@pytest.mark.unit +class TestBuildQueryPayload: + def test_full_payload(self): + query, segment, sub_segment = _build_query_payload(_valid_context()) + assert "Cliente reclama de cobrança indevida" in query + + def test_empty_speech_returns_empty_query(self): + ctx = {"speech_analytics": {}, "imdb_access_data": {}, "complaint": {}} + query, segment, sub_segment = _build_query_payload(ctx) + assert query == "" + assert segment is None + assert sub_segment is None + + def test_partial_speech(self): + ctx = { + "speech_analytics": {"reclamacao_resumo": "X"}, + "imdb_access_data": {}, + "complaint": {"inputChannel": "anatel"}, + } + query, _, _ = _build_query_payload(ctx) + assert "X" in query + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestKnowledgeBaseEnrichmentNode: + + @patch(TAIS_CLIENT_PATH) + async def test_happy_path_returns_top_k_unique_documents(self, mock_client_cls): + mock_client = AsyncMock() + # client already deduplicates internally; simulate that here + mock_client.search_documents.return_value = { + "sql": "SELECT * FROM table", + "results": [ + MOCK_DB_ROWS[0], + MOCK_DB_ROWS[2], + MOCK_DB_ROWS[3], + ], + } + mock_client_cls.return_value = mock_client + + state = make_state(request_context=_valid_context()) + final_state = await enrich_with_knowledge_base(state) + + assert final_state["current_step"] == "knowledge_base_enrichment" + rd = final_state["metadata"]["request_context"]["relevant_documents"] + assert "query" in rd + assert len(rd["documents"]) == 3 + assert rd["documents"][0]["title"] == "Procedimento Cobrança Pré-Pago" + assert rd["documents"][0]["distance"] == 0.21 + assert "chunk" in rd["documents"][0] + assert "message" not in rd + assert "_error" not in rd + + @patch(TAIS_CLIENT_PATH) + async def test_empty_query_payload_skips_client_and_returns_not_found(self, mock_client_cls): + state = make_state(request_context={ + "speech_analytics": {}, + "imdb_access_data": {}, + "complaint": {}, + }) + + final_state = await enrich_with_knowledge_base(state) + + assert final_state["current_step"] == "knowledge_base_enrichment" + rd = final_state["metadata"]["request_context"]["relevant_documents"] + assert rd["documents"] == [] + assert rd["message"] == "Procedimentos não encontrado" + mock_client_cls.assert_not_called() + + @patch(TAIS_CLIENT_PATH) + async def test_client_error_does_not_break_flow(self, mock_client_cls): + mock_client = AsyncMock() + mock_client.search_documents.side_effect = TaisKbClientError("DB unreachable") + mock_client_cls.return_value = mock_client + + state = make_state(request_context=_valid_context()) + final_state = await enrich_with_knowledge_base(state) + + assert final_state["current_step"] == "knowledge_base_enrichment_unavailable" + assert final_state.get("error") is None + rd = final_state["metadata"]["request_context"]["relevant_documents"] + assert rd["documents"] == [] + assert rd["message"] == "Indisponível consulta de Procedimento - realizar consulta manualmente" + assert "DB unreachable" in rd["_error"] + + @patch(TAIS_CLIENT_PATH) + async def test_unexpected_exception_does_not_break_flow(self, mock_client_cls): + mock_client = AsyncMock() + mock_client.search_documents.side_effect = RuntimeError("boom") + mock_client_cls.return_value = mock_client + + state = make_state(request_context=_valid_context()) + final_state = await enrich_with_knowledge_base(state) + + assert final_state["current_step"] == "knowledge_base_enrichment_unavailable" + assert final_state.get("error") is None + rd = final_state["metadata"]["request_context"]["relevant_documents"] + assert rd["message"] == "Indisponível consulta de Procedimento - realizar consulta manualmente" + assert "boom" in rd["_error"] + + @patch(TAIS_CLIENT_PATH) + async def test_client_returns_zero_documents(self, mock_client_cls): + mock_client = AsyncMock() + mock_client.search_documents.return_value = { + "sql": "SELECT * FROM table", + "results": [], + } + mock_client_cls.return_value = mock_client + + state = make_state(request_context=_valid_context()) + final_state = await enrich_with_knowledge_base(state) + + rd = final_state["metadata"]["request_context"]["relevant_documents"] + assert rd["documents"] == [] + assert rd["message"] == "Procedimentos não encontrado" + assert "_error" not in rd + + @patch(TAIS_CLIENT_PATH) + async def test_cache_hit_skips_client_call(self, mock_client_cls): + cached = { + "query": "old query", + "documents": [{"title": "T", "chunk": "C", "distance": 0.1}], + } + ctx = _valid_context() + ctx["relevant_documents"] = cached + + state = make_state(request_context=ctx) + final_state = await enrich_with_knowledge_base(state) + + assert final_state["current_step"] == "knowledge_base_enrichment" + assert final_state["metadata"]["request_context"]["relevant_documents"] is cached + mock_client_cls.assert_not_called() + + @patch(TAIS_CLIENT_PATH) + async def test_search_documents_called_with_correct_params(self, mock_client_cls): + from src.components.clients.tais_kb_client import Product + mock_client = AsyncMock() + mock_client.search_documents.return_value = { + "sql": "SELECT * FROM table", + "results": [], + } + mock_client_cls.return_value = mock_client + + state = make_state(request_context=_valid_context()) + await enrich_with_knowledge_base(state) + + from src.core.config import settings + call_kwargs = mock_client.search_documents.call_args.kwargs + assert call_kwargs["top_k"] == settings.TAIS_TOP_K + assert call_kwargs["product"] == Product.MOVEL diff --git a/tests_original_develop/unit/test_llm_factory.py b/tests_original_develop/unit/test_llm_factory.py new file mode 100644 index 0000000..27751fa --- /dev/null +++ b/tests_original_develop/unit/test_llm_factory.py @@ -0,0 +1,228 @@ +""" +Unit tests for LLM factory module. + +Tests the creation of LLM instances from different providers +and error handling for missing configuration. +""" + +import pytest +from unittest.mock import MagicMock +from langchain_openai import ChatOpenAI +from langchain_anthropic import ChatAnthropic +from agent_framework.components.llm.factory import LLM, call_llm_with_retry +from agent_framework.core.config import EligibleModel +from agent_framework.core.exceptions import ConfigurationError, LLMError +from src.core.config import Settings + + +class TestGetLLM: + """Tests for get_llm factory function.""" + + def test_get_llm_openai_success(self, monkeypatch): + """Test creating OpenAI LLM with valid configuration.""" + # Setup + monkeypatch.setattr( + "agent_framework.components.llm.factory.settings", + Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + LLM_TEMPERATURE=0.7, + LLM_MAX_TOKENS=1000, + OPENAI_API_KEY="test-key-123" + ) + ) + + # Execute + llm = LLM(EligibleModel.bo_gptoss20b_dev, 0.7, 1024, -1 ) + + # Assert + assert isinstance(llm, ChatOpenAI) + assert llm.model_name == "gpt-4" + assert llm.temperature == 0.7 + assert llm.max_tokens == 1000 + + def test_get_llm_anthropic_success(self, monkeypatch): + """Test creating Anthropic LLM with valid configuration.""" + # Setup + monkeypatch.setattr( + "agent_framework.components.llm.factory.settings", + Settings( + LLM_PROVIDER="anthropic", + LLM_MODEL="claude-3-opus-20240229", + LLM_TEMPERATURE=0.5, + LLM_MAX_TOKENS=2000, + ANTHROPIC_API_KEY="test-key-456" + ) + ) + + # Execute + llm = LLM(EligibleModel.bo_gptoss20b_dev, 0.7, 1024, -1 ) + + # Assert + assert isinstance(llm, ChatAnthropic) + assert llm.model == "claude-3-opus-20240229" + assert llm.temperature == 0.5 + assert llm.max_tokens == 2000 + + def test_get_llm_openai_missing_api_key(self, monkeypatch): + """Test that missing OpenAI API key raises ConfigurationError.""" + # Setup + monkeypatch.setattr( + "agent_framework.components.llm.factory.settings", + Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + OPENAI_API_KEY=None + ) + ) + + # Execute & Assert + with pytest.raises(ConfigurationError) as exc_info: + LLM(EligibleModel.bo_gptoss20b_dev, 0.7, 1024, -1 ) + + assert "OPENAI_API_KEY is required" in str(exc_info.value) + + def test_get_llm_anthropic_missing_api_key(self, monkeypatch): + """Test that missing Anthropic API key raises ConfigurationError.""" + # Setup + monkeypatch.setattr( + "agent_framework.components.llm.factory.settings", + Settings( + LLM_PROVIDER="anthropic", + LLM_MODEL="claude-3-opus-20240229", + ANTHROPIC_API_KEY=None + ) + ) + + # Execute & Assert + with pytest.raises(ConfigurationError) as exc_info: + LLM(EligibleModel.bo_gptoss20b_dev, 0.7, 1024, -1 ) + + assert "ANTHROPIC_API_KEY is required" in str(exc_info.value) + + def test_get_llm_unsupported_provider(self, monkeypatch): + """Test that unsupported provider raises ConfigurationError.""" + # Setup - need to bypass validation in Settings + settings_mock = MagicMock() + settings_mock.LLM_PROVIDER = "unsupported" + settings_mock.LLM_MODEL = "some-model" + + monkeypatch.setattr("agent_framework.components.llm.factory.settings", settings_mock) + + # Execute & Assert + with pytest.raises(ConfigurationError) as exc_info: + LLM(EligibleModel.bo_gptoss20b_dev, 0.7, 1024, -1 ) + + assert "Unsupported LLM provider" in str(exc_info.value) + assert "unsupported" in str(exc_info.value) + + +class TestCallLLMWithRetry: + """Tests for call_llm_with_retry function.""" + + @pytest.mark.asyncio + async def test_call_llm_success_first_attempt(self): + """Test successful LLM call on first attempt.""" + # Setup + mock_llm = MagicMock() + mock_response = MagicMock() + + # Create async mock + async def mock_ainvoke(msgs): + return mock_response + + mock_llm.ainvoke = mock_ainvoke + messages = [{"role": "user", "content": "Hello"}] + + # Execute + result = await call_llm_with_retry(mock_llm, messages) + + # Assert + assert result == mock_response + + @pytest.mark.asyncio + async def test_call_llm_retry_then_success(self): + """Test LLM call succeeds after retry.""" + # Setup + mock_llm = MagicMock() + mock_response = MagicMock() + + # Fail first call, succeed on second + call_count = 0 + async def mock_ainvoke(msgs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise Exception("Temporary network error") + return mock_response + + mock_llm.ainvoke = mock_ainvoke + messages = [{"role": "user", "content": "Hello"}] + + # Execute + result = await call_llm_with_retry(mock_llm, messages) + + # Assert + assert result == mock_response + assert call_count == 2 + + @pytest.mark.asyncio + async def test_call_llm_all_retries_fail(self): + """Test LLM call fails after all retries exhausted.""" + # Setup + mock_llm = MagicMock() + + async def mock_ainvoke(msgs): + raise Exception("Persistent error") + + mock_llm.ainvoke = mock_ainvoke + messages = [{"role": "user", "content": "Hello"}] + + # Execute & Assert + with pytest.raises(LLMError) as exc_info: + await call_llm_with_retry(mock_llm, messages) + + assert "Failed to get LLM response after retries" in str(exc_info.value) + assert "Persistent error" in str(exc_info.value) + + +class TestLLMFactoryIntegration: + """Integration tests for LLM factory with different configurations.""" + + def test_openai_with_default_max_tokens(self, monkeypatch): + """Test OpenAI LLM creation with default (None) max_tokens.""" + # Setup + monkeypatch.setattr( + "agent_framework.components.llm.factory.settings", + Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-3.5-turbo", + LLM_MAX_TOKENS=None, + OPENAI_API_KEY="test-key" + ) + ) + + # Execute + llm = LLM(EligibleModel.bo_gptoss20b_dev, 0.7, 1024, -1 ) + + # Assert + assert isinstance(llm, ChatOpenAI) + assert llm.max_tokens is None + + def test_case_insensitive_provider(self, monkeypatch): + """Test that provider name is case-insensitive.""" + # Setup + monkeypatch.setattr( + "agent_framework.components.llm.factory.settings", + Settings( + LLM_PROVIDER="openai", # lowercase + LLM_MODEL="gpt-4", + OPENAI_API_KEY="test-key" + ) + ) + + # Execute + llm = LLM(EligibleModel.bo_gptoss20b_dev, 0.7, 1024, -1 ) + + # Assert + assert isinstance(llm, ChatOpenAI) diff --git a/tests_original_develop/unit/test_llm_retry.py b/tests_original_develop/unit/test_llm_retry.py new file mode 100644 index 0000000..e00906f --- /dev/null +++ b/tests_original_develop/unit/test_llm_retry.py @@ -0,0 +1,50 @@ +import pytest +from unittest.mock import patch, MagicMock +import oci +from src.providers.llm_provider import chat_llm_with_usage, classification_llm + +@patch("oci.generative_ai_inference.GenerativeAiInferenceClient.chat") +def test_chat_llm_retry_on_timeout(mock_chat): + """ + Testa se o chat_llm_with_usage executa retries quando recebe um ServiceError (504). + Simula 2 falhas seguidas e sucesso na 3ª tentativa. + """ + # ── Montagem do Cenário ────────────────────────────────────────────────── + # Criamos o erro que o retry deve capturar + timeout_error = oci.exceptions.ServiceError( + status=504, + code="GatewayTimeout", + message="Request timed out", + headers={}, + operation_name="Chat" + ) + + # Simula resposta de sucesso na 3ª chamada + mock_response = MagicMock() + chat_response = mock_response.data.chat_response + + # Configuramos os valores de retorno para serem tipos primitivos (não Mocks) + chat_response.choices = [ + MagicMock( + message=MagicMock(content=[MagicMock(text="Sucesso após retry!")]), + finish_reason="stop" + ) + ] + chat_response.model = "cohere.command-r-plus" + chat_response.usage.prompt_tokens = 10 + chat_response.usage.completion_tokens = 10 + chat_response.usage.total_tokens = 20 + + # Configuramos os side_effects: Falha, Falha, Sucesso + mock_chat.side_effect = [timeout_error, timeout_error, mock_response] + + # ── Execução ───────────────────────────────────────────────────────────── + prompt = "Teste de retry" + result = chat_llm_with_usage(classification_llm, prompt) + + # ── Verificação ────────────────────────────────────────────────────────── + # O resultado final deve ser o da 3ª chamada + assert result.content == "Sucesso após retry!" + + # Verificamos se o client.chat foi chamado exatamente 3 vezes + assert mock_chat.call_count == 3 diff --git a/tests_original_develop/unit/test_logging.py b/tests_original_develop/unit/test_logging.py new file mode 100644 index 0000000..968a5bc --- /dev/null +++ b/tests_original_develop/unit/test_logging.py @@ -0,0 +1,420 @@ +""" +Testes unitários para o módulo de logging. +""" + +import logging +import json +from io import StringIO +import pytest + +import src.core.logging as _logging_module +from src.core.logging import ( + setup_logging, + get_logger, + set_request_id, + set_session_id, + set_user_id, + clear_context, + MetricsLogger, + ContextFilter, + _StructuredFormatter, +) + + +@pytest.fixture +def reset_logging(): + """Reset logging configuration and singleton state after each test.""" + yield + root = logging.getLogger() + for handler in root.handlers[:]: + root.removeHandler(handler) + _logging_module._configured = False + clear_context() + + +@pytest.fixture +def capture_logs(): + """Capture log output to a string buffer""" + log_capture = StringIO() + handler = logging.StreamHandler(log_capture) + handler.setLevel(logging.DEBUG) + + # Use simple formatter for easier testing + formatter = logging.Formatter('%(levelname)s - %(name)s - %(message)s') + handler.setFormatter(formatter) + + logger = logging.getLogger() + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + + yield log_capture + + logger.removeHandler(handler) + + +def test_setup_logging_creates_logger(reset_logging): + """Test that setup_logging creates and configures a logger""" + logger = setup_logging() + + assert logger is not None + assert isinstance(logger, logging.Logger) + assert len(logger.handlers) > 0 + + +def test_get_logger_returns_logger(): + """Test that get_logger returns a logger with the specified name""" + logger = get_logger("test_module") + + assert logger is not None + assert logger.name == "test_module" + + +def test_context_filter_adds_request_id(): + """Test that ContextFilter adds request_id to log records""" + set_request_id("test-request-123") + + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="test message", + args=(), + exc_info=None + ) + + filter = ContextFilter() + filter.filter(record) + + assert hasattr(record, 'request_id') + assert record.request_id == "test-request-123" + + clear_context() + + +def test_context_filter_adds_session_id(): + """Test that ContextFilter adds session_id to log records""" + set_session_id("session-456") + + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="test message", + args=(), + exc_info=None + ) + + filter = ContextFilter() + filter.filter(record) + + assert hasattr(record, 'session_id') + assert record.session_id == "session-456" + + clear_context() + + +def test_context_filter_adds_user_id(): + """Test that ContextFilter adds user_id to log records""" + set_user_id("user-789") + + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="test message", + args=(), + exc_info=None + ) + + filter = ContextFilter() + filter.filter(record) + + assert hasattr(record, 'user_id') + assert record.user_id == "user-789" + + clear_context() + + +def test_set_and_clear_context(): + """Test setting and clearing context variables""" + set_request_id("req-1") + set_session_id("sess-1") + set_user_id("user-1") + + # Create a record and apply filter + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="test", + args=(), + exc_info=None + ) + + filter = ContextFilter() + filter.filter(record) + + assert record.request_id == "req-1" + assert record.session_id == "sess-1" + assert record.user_id == "user-1" + + # Clear context + clear_context() + + # Create new record + record2 = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="test", + args=(), + exc_info=None + ) + + filter.filter(record2) + + assert record2.request_id is None + assert record2.session_id is None + assert record2.user_id is None + + +def test_metrics_logger_measures_time(): + """Test that MetricsLogger correctly measures elapsed time""" + import time + + metrics = MetricsLogger("test_operation") + metrics.start() + + time.sleep(0.1) # Sleep for 100ms + + elapsed = metrics.stop() + + assert elapsed >= 0.1 + assert elapsed < 0.2 # Should be close to 0.1 + + +def test_metrics_logger_logs_basic_metrics(capture_logs): + """Test that MetricsLogger logs basic metrics""" + logger = get_logger("test") + metrics = MetricsLogger("test_op", logger=logger) + + metrics.start() + metrics.stop() + metrics.log() + + log_output = capture_logs.getvalue() + assert "test_op" in log_output or "Metrics" in log_output + + +def test_metrics_logger_logs_tokens_and_cost(capture_logs): + """Test that MetricsLogger logs tokens and cost""" + logger = get_logger("test") + metrics = MetricsLogger("llm_call", logger=logger) + + metrics.start() + metrics.stop() + metrics.log(tokens=150, cost=0.003) + + log_output = capture_logs.getvalue() + assert "llm_call" in log_output or "Metrics" in log_output + + +def test_metrics_logger_logs_extra_metrics(capture_logs): + """Test that MetricsLogger logs extra metrics""" + logger = get_logger("test") + metrics = MetricsLogger("custom_op", logger=logger) + + metrics.start() + metrics.stop() + metrics.log(custom_metric=42, another_metric="value") + + log_output = capture_logs.getvalue() + assert "custom_op" in log_output or "Metrics" in log_output + + +def test_metrics_logger_auto_stops_on_log(): + """Test that MetricsLogger automatically stops when logging""" + import time + + metrics = MetricsLogger("test_op") + metrics.start() + + # Sleep a tiny bit to ensure some time passes + time.sleep(0.001) + + # Don't call stop() explicitly + assert metrics.end_time is None + + metrics.log() + + # Should have stopped automatically + assert metrics.end_time is not None + assert metrics.elapsed_time >= 0 # Changed from > 0 to >= 0 + + +def test_logging_different_levels(capture_logs): + """Test logging at different levels""" + logger = get_logger("test") + + logger.debug("Debug message") + logger.info("Info message") + logger.warning("Warning message") + logger.error("Error message") + + log_output = capture_logs.getvalue() + + # Depending on log level, some messages might not appear + # But at least one should be there + assert len(log_output) > 0 + + +def test_context_persists_across_multiple_logs(capture_logs): + """Test that context persists across multiple log calls""" + set_request_id("persistent-req") + set_session_id("persistent-sess") + + logger = get_logger("test") + logger.info("First log") + logger.info("Second log") + logger.info("Third log") + + # Context should persist for all logs + # This is verified by the ContextFilter being applied to all records + + clear_context() + + +@pytest.mark.parametrize("level", [ + logging.DEBUG, + logging.INFO, + logging.WARNING, + logging.ERROR, +]) +def test_metrics_logger_respects_log_level(level): + """Test that MetricsLogger respects different log levels""" + logger = get_logger("test") + metrics = MetricsLogger("test_op", logger=logger) + + metrics.start() + metrics.stop() + + # Should not raise an error + metrics.log(level=level) + + +def test_metrics_logger_elapsed_time_before_stop(): + """Test that elapsed_time works even before stop() is called""" + import time + + metrics = MetricsLogger("test_op") + metrics.start() + + time.sleep(0.05) + + # Should return current elapsed time even without stop() + elapsed = metrics.elapsed_time + assert elapsed >= 0.05 + assert elapsed < 0.1 + + +def test_metrics_logger_without_start(): + """Test that MetricsLogger handles case where start() wasn't called""" + metrics = MetricsLogger("test_op") + + # Don't call start() + elapsed = metrics.elapsed_time + + assert elapsed == 0.0 + + +# --------------------------------------------------------------------------- +# Correlation fields — session_id, user_id, request_id must always be present +# --------------------------------------------------------------------------- + +def _make_record(msg: str = "test") -> logging.LogRecord: + record = logging.LogRecord( + name="test", level=logging.INFO, pathname="", + lineno=0, msg=msg, args=(), exc_info=None, + ) + ContextFilter().filter(record) + return record + + +def test_json_always_has_correlation_fields_when_context_empty(): + """JSON output must contain request_id/session_id/user_id even when None.""" + clear_context() + formatter = _StructuredFormatter(use_json=True) + output = formatter.format(_make_record()) + obj = json.loads(output) + + assert "request_id" in obj + assert "session_id" in obj + assert "user_id" in obj + assert obj["request_id"] is None + assert obj["session_id"] is None + assert obj["user_id"] is None + + +def test_json_correlation_fields_carry_set_values(): + """JSON output must carry the values set via set_* helpers.""" + set_request_id("req-abc") + set_session_id("sess-xyz") + set_user_id("user-123") + + formatter = _StructuredFormatter(use_json=True) + output = formatter.format(_make_record()) + obj = json.loads(output) + + assert obj["request_id"] == "req-abc" + assert obj["session_id"] == "sess-xyz" + assert obj["user_id"] == "user-123" + + clear_context() + + +def test_text_always_has_correlation_fields_when_context_empty(): + """Text output must contain the three correlation keys even when None.""" + clear_context() + formatter = _StructuredFormatter(use_json=False) + output = formatter.format(_make_record()) + + assert "request_id=None" in output + assert "session_id=None" in output + assert "user_id=None" in output + + +def test_text_correlation_fields_carry_set_values(): + """Text output must carry the values set via set_* helpers.""" + set_request_id("req-t1") + set_session_id("sess-t1") + set_user_id("user-t1") + + formatter = _StructuredFormatter(use_json=False) + output = formatter.format(_make_record()) + + assert "request_id=req-t1" in output + assert "session_id=sess-t1" in output + assert "user_id=user-t1" in output + + clear_context() + + +def test_json_correlation_fields_reset_after_clear_context(): + """After clear_context, correlation fields revert to None in JSON.""" + set_request_id("req-temp") + set_session_id("sess-temp") + set_user_id("user-temp") + clear_context() + + formatter = _StructuredFormatter(use_json=True) + output = formatter.format(_make_record()) + obj = json.loads(output) + + assert obj["request_id"] is None + assert obj["session_id"] is None + assert obj["user_id"] is None diff --git a/tests_original_develop/unit/test_siebel_classification_node.py b/tests_original_develop/unit/test_siebel_classification_node.py new file mode 100644 index 0000000..a11b2bc --- /dev/null +++ b/tests_original_develop/unit/test_siebel_classification_node.py @@ -0,0 +1,164 @@ +"""Unit tests for siebel_classification_node.""" + +import pytest +from unittest.mock import patch +from langchain_core.messages import HumanMessage + +from src.agent.nodes import siebel_classification_node +from src.agent.nodes.siebel_classification_node import _classify_call, _CLASSIFICATION_REASONS + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_state(request_context: dict = None) -> dict: + """Build a minimal AgentState for testing.""" + return { + "messages": [HumanMessage(content="test")], + "current_step": "enriched", + "iteration_count": 1, + "tool_results": None, + "final_response": None, + "session_id": "test-session", + "metadata": { + "request_context": request_context or { + "customer": {"cpf_cnpj": "06252533106", "claimed_msisdn": "62981152324"}, + "channel": "Anatel", + "notes": "test note", + } + }, + "error": None, + } + + +# --------------------------------------------------------------------------- +# _classify_call (pure-function tests — no state, no mocks needed) +# --------------------------------------------------------------------------- + +@pytest.mark.unit +def test_classify_call_default_returns_fallback(): + """When random_choice is False, always returns 'fallback'.""" + for _ in range(5): + assert _classify_call(random_choice=False) == "fallback" + + +@pytest.mark.unit +def test_classify_call_random_returns_valid_classification(): + """When random_choice is True, returns one of the expected classifications.""" + valid = set(_CLASSIFICATION_REASONS.keys()) + for _ in range(20): + result = _classify_call(random_choice=True) + assert result in valid, f"Unexpected classification: {result}" + + +@pytest.mark.unit +def test_classification_reasons_has_required_keys(): + """Every entry in _CLASSIFICATION_REASONS must have reason1, reason2, reason3.""" + for classification, reasons in _CLASSIFICATION_REASONS.items(): + for key in ("reason1", "reason2", "reason3"): + assert key in reasons, ( + f"'{classification}' is missing '{key}' in _CLASSIFICATION_REASONS" + ) + + +# --------------------------------------------------------------------------- +# process() — node tests +# --------------------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_sets_current_step_to_classified(): + """After process(), current_step must be 'ticket_classified'.""" + state = _make_state() + result = await siebel_classification_node.process(state) + assert result["current_step"] == "ticket_classified" + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_writes_classification_to_context(): + """process() must write 'classification' key into request_context.""" + state = _make_state() + result = await siebel_classification_node.process(state) + context = result["metadata"]["request_context"] + assert "classification" in context + assert context["classification"] in _CLASSIFICATION_REASONS + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_writes_reasons_to_context(): + """process() must write reason1, reason2 and reason3 into request_context.""" + state = _make_state() + result = await siebel_classification_node.process(state) + context = result["metadata"]["request_context"] + for key in ("reason1", "reason2", "reason3"): + assert key in context + assert isinstance(context[key], str) + assert context[key] # non-empty + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_preserves_existing_context_fields(): + """process() must not remove pre-existing context fields.""" + original_ctx = { + "customer": {"cpf_cnpj": "123", "claimed_msisdn": "456"}, + "channel": "Anatel", + "notes": "important note", + } + state = _make_state(request_context=original_ctx) + result = await siebel_classification_node.process(state) + ctx = result["metadata"]["request_context"] + + assert ctx.get("customer") == original_ctx["customer"] + assert ctx.get("channel") == original_ctx["channel"] + assert ctx.get("notes") == original_ctx["notes"] + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_reasons_match_classification(): + """The reasons written to context must match the _CLASSIFICATION_REASONS dict.""" + with patch.object(siebel_classification_node, "_classify_call", return_value="tratamento"): + state = _make_state() + result = await siebel_classification_node.process(state) + + ctx = result["metadata"]["request_context"] + expected = _CLASSIFICATION_REASONS["tratamento"] + assert ctx["reason1"] == expected["reason1"] + assert ctx["reason2"] == expected["reason2"] + assert ctx["reason3"] == expected["reason3"] + + +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize("classification", ["tratamento", "fallback", "ancelado"]) +async def test_process_all_classifications(classification: str): + """process() must handle every known classification correctly.""" + with patch.object(siebel_classification_node, "_classify_call", return_value=classification): + state = _make_state() + result = await siebel_classification_node.process(state) + + ctx = result["metadata"]["request_context"] + assert ctx["classification"] == classification + expected = _CLASSIFICATION_REASONS[classification] + assert ctx["reason1"] == expected["reason1"] + assert ctx["reason2"] == expected["reason2"] + assert ctx["reason3"] == expected["reason3"] + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_fallback_used_for_unknown_classification(): + """If _classify_call returns an unknown value, fallback reasons should be used.""" + with patch.object(siebel_classification_node, "_classify_call", return_value="unknown_type"): + state = _make_state() + result = await siebel_classification_node.process(state) + + ctx = result["metadata"]["request_context"] + expected = _CLASSIFICATION_REASONS["fallback"] + assert ctx["reason1"] == expected["reason1"] + assert ctx["reason2"] == expected["reason2"] + assert ctx["reason3"] == expected["reason3"] diff --git a/tests_original_develop/unit/test_siebel_client.py b/tests_original_develop/unit/test_siebel_client.py new file mode 100644 index 0000000..693eccc --- /dev/null +++ b/tests_original_develop/unit/test_siebel_client.py @@ -0,0 +1,389 @@ +"""Unit tests for SiebelClient.""" + +import base64 +import pytest +import httpx +from unittest.mock import AsyncMock, MagicMock, patch + +from src.components.clients.siebel_client import SiebelClient +from src.components.clients.exceptions.siebel_exceptions import ( + SiebelClientError, + SiebelHttpError, + SiebelConnectionError, + SiebelTimeoutError, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SAMPLE_PAYLOAD = { + "channel": "Anatel", + "socialSecNo": "06252533106", + "assetId": "62981152324", + "serviceRequest": { + "type": "0119", + "userId": "SADMIN", + "reason1": "Processo Interno", + "reason2": "Turbina", + "reason3": "Internalização", + "status": "Encaminhado", + "notes": "Teste", + }, + "interaction": { + "source": "Cliente", + "requestFlag": False, + "directionContact": "FROM-CLIENT", + "status": "OPENED", + }, +} + +_SAMPLE_RESPONSE = {"interactionProtocol": "2026000099999"} + + +def _make_client(**kwargs) -> SiebelClient: + """Instantiate SiebelClient with test defaults using patched settings.""" + with patch("src.components.clients.siebel_client.settings") as mock_settings: + mock_settings.SIEBEL_API_HOST = "https://fake-siebel.example.com" + mock_settings.SIEBEL_API_TIMEOUT = 30 + mock_settings.VERIFY_SSL = False + mock_settings.SIEBEL_API_USERNAME = "testuser" + mock_settings.SIEBEL_API_PASSWORD = "testpass" + mock_settings.SIEBEL_API_CLIENT_ID = "test-client-id" + mock_settings.LOG_LEVEL = "INFO" + mock_settings.LOG_FORMAT = "text" + return SiebelClient(**kwargs) + + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- + +@pytest.mark.unit +def test_client_uses_settings_defaults(): + """When no args are given, client falls back to settings values.""" + with patch("src.components.clients.siebel_client.settings") as mock_settings: + mock_settings.SIEBEL_API_HOST = "https://default.example.com" + mock_settings.SIEBEL_API_TIMEOUT = 60 + mock_settings.VERIFY_SSL = True + mock_settings.SIEBEL_API_USERNAME = "user" + mock_settings.SIEBEL_API_PASSWORD = "pass" + mock_settings.SIEBEL_API_CLIENT_ID = "cid" + mock_settings.LOG_LEVEL = "INFO" + mock_settings.LOG_FORMAT = "text" + client = SiebelClient() + + assert client.base_url == "https://default.example.com" + assert client.timeout == 60 + assert client.verify_ssl is True + assert client.username == "user" + assert client.password == "pass" + assert client.client_id == "cid" + + +@pytest.mark.unit +def test_client_explicit_args_override_settings(): + """Explicit constructor arguments must take precedence over settings.""" + client = _make_client( + base_url="https://override.example.com", + timeout=10, + verify_ssl=True, + username="override_user", + password="override_pass", + client_id="override_cid", + ) + assert client.base_url == "https://override.example.com" + assert client.timeout == 10 + assert client.verify_ssl is True + assert client.username == "override_user" + assert client.password == "override_pass" + assert client.client_id == "override_cid" + + +@pytest.mark.unit +def test_client_accepts_verify_ssl_false_explicitly(): + """verify_ssl=False must not be overridden by settings (falsy-value bug guard).""" + with patch("src.components.clients.siebel_client.settings") as mock_settings: + mock_settings.SIEBEL_API_HOST = "https://x.com" + mock_settings.SIEBEL_API_TIMEOUT = 30 + mock_settings.VERIFY_SSL = True # settings say True + mock_settings.SIEBEL_API_USERNAME = "u" + mock_settings.SIEBEL_API_PASSWORD = "p" + mock_settings.SIEBEL_API_CLIENT_ID = "c" + mock_settings.LOG_LEVEL = "INFO" + mock_settings.LOG_FORMAT = "text" + client = SiebelClient(verify_ssl=False) # explicit False must win + + assert client.verify_ssl is False + + +@pytest.mark.unit +def test_client_accepts_timeout_zero_explicitly(): + """timeout=0 must not be overridden by settings (falsy-value bug guard).""" + with patch("src.components.clients.siebel_client.settings") as mock_settings: + mock_settings.SIEBEL_API_HOST = "https://x.com" + mock_settings.SIEBEL_API_TIMEOUT = 30 # settings say 30 + mock_settings.VERIFY_SSL = False + mock_settings.SIEBEL_API_USERNAME = "u" + mock_settings.SIEBEL_API_PASSWORD = "p" + mock_settings.SIEBEL_API_CLIENT_ID = "c" + mock_settings.LOG_LEVEL = "INFO" + mock_settings.LOG_FORMAT = "text" + client = SiebelClient(timeout=0) # explicit 0 must win + + assert client.timeout == 0 + + +# --------------------------------------------------------------------------- +# _get_auth_header +# --------------------------------------------------------------------------- + +@pytest.mark.unit +def test_get_auth_header_returns_basic_header(): + """_get_auth_header must return a valid Basic Auth header.""" + client = _make_client(username="user", password="pass") + header = client._get_auth_header() + + expected = "Basic " + base64.b64encode(b"user:pass").decode() + assert header == expected + + +@pytest.mark.unit +def test_get_auth_header_returns_empty_when_no_credentials(): + """_get_auth_header must return '' when username and password are absent from settings.""" + with patch("src.components.clients.siebel_client.settings") as mock_settings: + mock_settings.SIEBEL_API_HOST = "https://fake-siebel.example.com" + mock_settings.SIEBEL_API_TIMEOUT = 30 + mock_settings.VERIFY_SSL = False + mock_settings.SIEBEL_API_USERNAME = "" + mock_settings.SIEBEL_API_PASSWORD = "" + mock_settings.SIEBEL_API_CLIENT_ID = "" + mock_settings.LOG_LEVEL = "INFO" + mock_settings.LOG_FORMAT = "text" + client = SiebelClient() + + assert client._get_auth_header() == "" + + +@pytest.mark.unit +def test_get_auth_header_returns_empty_when_password_missing(): + """_get_auth_header must return '' when only password is absent from settings.""" + with patch("src.components.clients.siebel_client.settings") as mock_settings: + mock_settings.SIEBEL_API_HOST = "https://fake-siebel.example.com" + mock_settings.SIEBEL_API_TIMEOUT = 30 + mock_settings.VERIFY_SSL = False + mock_settings.SIEBEL_API_USERNAME = "user" + mock_settings.SIEBEL_API_PASSWORD = "" + mock_settings.SIEBEL_API_CLIENT_ID = "" + mock_settings.LOG_LEVEL = "INFO" + mock_settings.LOG_FORMAT = "text" + client = SiebelClient() + + assert client._get_auth_header() == "" + + +# --------------------------------------------------------------------------- +# open_service_request — happy path +# --------------------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_open_service_request_returns_json_response(): + """On HTTP 200, open_service_request must return the parsed JSON body.""" + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = _SAMPLE_RESPONSE + + mock_http_client = AsyncMock() + mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client) + mock_http_client.__aexit__ = AsyncMock(return_value=False) + mock_http_client.post = AsyncMock(return_value=mock_response) + + client = _make_client() + with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client): + result = await client.open_service_request(payload=_SAMPLE_PAYLOAD) + + assert result == _SAMPLE_RESPONSE + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_open_service_request_sends_authorization_header(): + """Request must include the Authorization header when credentials are set.""" + captured_headers = {} + + async def fake_post(url, json, headers): + captured_headers.update(headers) + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = _SAMPLE_RESPONSE + return mock_response + + mock_http_client = AsyncMock() + mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client) + mock_http_client.__aexit__ = AsyncMock(return_value=False) + mock_http_client.post = fake_post + + client = _make_client(username="user", password="pass", client_id="cid") + with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client): + await client.open_service_request(payload=_SAMPLE_PAYLOAD) + + assert "Authorization" in captured_headers + assert captured_headers["Authorization"].startswith("Basic ") + assert captured_headers["clientId"] == "cid" + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_open_service_request_no_auth_header_when_no_credentials(): + """When settings have no credentials, Authorization header must not be sent.""" + captured_headers = {} + + async def fake_post(url, json, headers): + captured_headers.update(headers) + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = _SAMPLE_RESPONSE + return mock_response + + mock_http_client = AsyncMock() + mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client) + mock_http_client.__aexit__ = AsyncMock(return_value=False) + mock_http_client.post = fake_post + + with patch("src.components.clients.siebel_client.settings") as mock_settings: + mock_settings.SIEBEL_API_HOST = "https://fake-siebel.example.com" + mock_settings.SIEBEL_API_TIMEOUT = 30 + mock_settings.VERIFY_SSL = False + mock_settings.SIEBEL_API_USERNAME = "" + mock_settings.SIEBEL_API_PASSWORD = "" + mock_settings.SIEBEL_API_CLIENT_ID = "" + mock_settings.LOG_LEVEL = "INFO" + mock_settings.LOG_FORMAT = "text" + client = SiebelClient() + + with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client): + await client.open_service_request(payload=_SAMPLE_PAYLOAD) + + assert "Authorization" not in captured_headers + + +# --------------------------------------------------------------------------- +# open_service_request — error handling +# --------------------------------------------------------------------------- + +def _make_http_status_error(status_code: int, body: str) -> httpx.HTTPStatusError: + """Build a realistic httpx.HTTPStatusError for testing.""" + response = MagicMock(spec=httpx.Response) + response.status_code = status_code + response.text = body + request = MagicMock(spec=httpx.Request) + return httpx.HTTPStatusError(message=body, request=request, response=response) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_open_service_request_raises_siebel_http_error_on_4xx(): + """HTTPStatusError from httpx must be wrapped into SiebelHttpError.""" + mock_http_client = AsyncMock() + mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client) + mock_http_client.__aexit__ = AsyncMock(return_value=False) + mock_http_client.post = AsyncMock( + side_effect=_make_http_status_error(400, "Bad Request") + ) + + client = _make_client() + with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client): + with pytest.raises(SiebelHttpError) as exc_info: + await client.open_service_request(payload=_SAMPLE_PAYLOAD) + + assert exc_info.value.status_code == 400 + assert "400" in str(exc_info.value) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_open_service_request_raises_siebel_http_error_on_5xx(): + """5xx responses must also raise SiebelHttpError.""" + mock_http_client = AsyncMock() + mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client) + mock_http_client.__aexit__ = AsyncMock(return_value=False) + mock_http_client.post = AsyncMock( + side_effect=_make_http_status_error(550, "Generic Error") + ) + + client = _make_client() + with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client): + with pytest.raises(SiebelHttpError) as exc_info: + await client.open_service_request(payload=_SAMPLE_PAYLOAD) + + assert exc_info.value.status_code == 550 + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_open_service_request_raises_siebel_http_error_is_subclass_of_base(): + """SiebelHttpError must be catchable via SiebelClientError.""" + mock_http_client = AsyncMock() + mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client) + mock_http_client.__aexit__ = AsyncMock(return_value=False) + mock_http_client.post = AsyncMock( + side_effect=_make_http_status_error(500, "Server Error") + ) + + client = _make_client() + with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client): + with pytest.raises(SiebelClientError): + await client.open_service_request(payload=_SAMPLE_PAYLOAD) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_open_service_request_raises_siebel_connection_error(): + """httpx.ConnectError must be wrapped into SiebelConnectionError.""" + mock_http_client = AsyncMock() + mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client) + mock_http_client.__aexit__ = AsyncMock(return_value=False) + mock_http_client.post = AsyncMock( + side_effect=httpx.ConnectError("Connection refused") + ) + + client = _make_client() + with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client): + with pytest.raises(SiebelConnectionError): + await client.open_service_request(payload=_SAMPLE_PAYLOAD) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_open_service_request_raises_siebel_timeout_error(): + """httpx.TimeoutException must be wrapped into SiebelTimeoutError.""" + mock_http_client = AsyncMock() + mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client) + mock_http_client.__aexit__ = AsyncMock(return_value=False) + mock_http_client.post = AsyncMock( + side_effect=httpx.TimeoutException("Request timed out") + ) + + client = _make_client() + with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client): + with pytest.raises(SiebelTimeoutError): + await client.open_service_request(payload=_SAMPLE_PAYLOAD) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_open_service_request_raises_siebel_client_error_on_unexpected(): + """Unexpected exceptions must be wrapped into the base SiebelClientError.""" + mock_http_client = AsyncMock() + mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client) + mock_http_client.__aexit__ = AsyncMock(return_value=False) + mock_http_client.post = AsyncMock( + side_effect=RuntimeError("Something totally unexpected") + ) + + client = _make_client() + with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client): + with pytest.raises(SiebelClientError): + await client.open_service_request(payload=_SAMPLE_PAYLOAD) diff --git a/tests_original_develop/unit/test_siebel_sr_opening_node.py b/tests_original_develop/unit/test_siebel_sr_opening_node.py new file mode 100644 index 0000000..a19f40a --- /dev/null +++ b/tests_original_develop/unit/test_siebel_sr_opening_node.py @@ -0,0 +1,215 @@ +"""Unit tests for siebel_sr_opening_node.""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from langchain_core.messages import HumanMessage + +from src.agent.nodes import siebel_sr_opening_node +from src.components.clients.exceptions.siebel_exceptions import ( + SiebelClientError, + SiebelHttpError, + SiebelConnectionError, + SiebelTimeoutError, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_BASE_CONTEXT = { + "customer": { + "cpf_cnpj": "06252533106", + "claimed_msisdn": "62981152324", + }, + "channel": "Anatel", + "reason1": "Processo Interno", + "reason2": "Turbina", + "reason3": "Internalização", + "notes": "Teste de abertura de SR", +} + +_SIEBEL_SUCCESS_RESPONSE = {"interactionProtocol": "2026000099999"} + + +def _make_state(request_context: dict = None, omit_context: bool = False) -> dict: + """Build a minimal AgentState for testing.""" + metadata = {} if omit_context else {"request_context": request_context or _BASE_CONTEXT.copy()} + return { + "messages": [HumanMessage(content="test")], + "current_step": "ticket_classified", + "iteration_count": 1, + "tool_results": None, + "final_response": None, + "session_id": "test-session", + "metadata": metadata, + "error": None, + } + + +def _make_mock_client(response: dict = None, side_effect=None): + """Return a mock SiebelClient with open_service_request pre-configured.""" + mock = MagicMock() + if side_effect: + mock.open_service_request = AsyncMock(side_effect=side_effect) + else: + mock.open_service_request = AsyncMock(return_value=response or _SIEBEL_SUCCESS_RESPONSE) + return mock + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_success_sets_step_to_opened(): + """On success, current_step must be 'siebel_sr_opened'.""" + mock_client = _make_mock_client() + with patch("src.agent.nodes.siebel_sr_opening_node.SiebelClient", return_value=mock_client): + state = _make_state() + result = await siebel_sr_opening_node.process(state) + + assert result["current_step"] == "siebel_sr_opened" + assert result["error"] is None + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_success_stores_siebel_response_in_context(): + """On success, the Siebel response must be written to request_context['siebel_sr_data'].""" + mock_client = _make_mock_client() + with patch("src.agent.nodes.siebel_sr_opening_node.SiebelClient", return_value=mock_client): + state = _make_state() + result = await siebel_sr_opening_node.process(state) + + ctx = result["metadata"]["request_context"] + assert "siebel_sr_data" in ctx + assert ctx["siebel_sr_data"] == _SIEBEL_SUCCESS_RESPONSE + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_calls_client_with_correct_payload(): + """open_service_request must be called with the expected payload fields.""" + mock_client = _make_mock_client() + with patch("src.agent.nodes.siebel_sr_opening_node.SiebelClient", return_value=mock_client): + state = _make_state() + await siebel_sr_opening_node.process(state) + + call_kwargs = mock_client.open_service_request.call_args + payload = call_kwargs.kwargs.get("payload") or call_kwargs.args[0] + + assert payload["socialSecNo"] == _BASE_CONTEXT["customer"]["cpf_cnpj"] + assert payload["assetId"] == _BASE_CONTEXT["customer"]["claimed_msisdn"] + assert payload["serviceRequest"]["reason1"] == _BASE_CONTEXT["reason1"] + assert payload["serviceRequest"]["reason2"] == _BASE_CONTEXT["reason2"] + assert payload["serviceRequest"]["reason3"] == _BASE_CONTEXT["reason3"] + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_passes_notes_in_payload(): + """Notes from context must be forwarded to the payload.""" + mock_client = _make_mock_client() + with patch("src.agent.nodes.siebel_sr_opening_node.SiebelClient", return_value=mock_client): + state = _make_state() + await siebel_sr_opening_node.process(state) + + payload = mock_client.open_service_request.call_args.kwargs.get("payload") \ + or mock_client.open_service_request.call_args.args[0] + assert payload["serviceRequest"]["notes"] == _BASE_CONTEXT["notes"] + + +# --------------------------------------------------------------------------- +# Validation errors +# --------------------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_missing_request_context_returns_error(): + """When request_context is absent, node must return a ValidationError.""" + state = _make_state(omit_context=True) + result = await siebel_sr_opening_node.process(state) + + assert result["error"] is not None + assert result["error"]["type"] == "ValidationError" + assert "request_context" in result["error"]["message"] + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_missing_cpf_returns_error(): + """When CPF is absent, node must return a ValidationError.""" + ctx = _BASE_CONTEXT.copy() + ctx["customer"] = {"claimed_msisdn": "62981152324"} # no cpf_cnpj + state = _make_state(request_context=ctx) + result = await siebel_sr_opening_node.process(state) + + assert result["error"] is not None + assert result["error"]["type"] == "ValidationError" + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_missing_msisdn_returns_error(): + """When MSISDN is absent, node must return a ValidationError.""" + ctx = _BASE_CONTEXT.copy() + ctx["customer"] = {"cpf_cnpj": "06252533106"} # no claimed_msisdn + state = _make_state(request_context=ctx) + result = await siebel_sr_opening_node.process(state) + + assert result["error"] is not None + assert result["error"]["type"] == "ValidationError" + + +# --------------------------------------------------------------------------- +# Siebel client errors +# --------------------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize( + "exception, expected_error_type", + [ + (SiebelHttpError(550, "Generic Error"), "SiebelHttpError"), + (SiebelConnectionError("Connection refused"), "SiebelConnectionError"), + (SiebelTimeoutError("Request timed out"), "SiebelTimeoutError"), + (SiebelClientError("Unexpected error"), "SiebelClientError"), + ], +) +async def test_process_client_errors_set_error_in_state(exception, expected_error_type): + """All SiebelClientError subclasses must be caught and stored in state['error'].""" + mock_client = _make_mock_client(side_effect=exception) + with patch("src.agent.nodes.siebel_sr_opening_node.SiebelClient", return_value=mock_client): + state = _make_state() + result = await siebel_sr_opening_node.process(state) + + assert result["error"] is not None + assert result["error"]["type"] == expected_error_type + assert result["error"]["step"] == "siebel_sr_opening" + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_http_error_does_not_raise(): + """Node must not propagate SiebelHttpError — it must catch and return error state.""" + mock_client = _make_mock_client(side_effect=SiebelHttpError(400, "Bad Request")) + with patch("src.agent.nodes.siebel_sr_opening_node.SiebelClient", return_value=mock_client): + state = _make_state() + result = await siebel_sr_opening_node.process(state) # must not raise + + assert result["error"] is not None + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_error_does_not_update_siebel_sr_data(): + """On error, siebel_sr_data must not be set in context.""" + mock_client = _make_mock_client(side_effect=SiebelConnectionError("no route to host")) + with patch("src.agent.nodes.siebel_sr_opening_node.SiebelClient", return_value=mock_client): + state = _make_state() + result = await siebel_sr_opening_node.process(state) + + ctx = result["metadata"].get("request_context", {}) + assert "siebel_sr_data" not in ctx diff --git a/tests_original_develop/unit/test_validation.py b/tests_original_develop/unit/test_validation.py new file mode 100644 index 0000000..1c0484c --- /dev/null +++ b/tests_original_develop/unit/test_validation.py @@ -0,0 +1,120 @@ +import pytest +from datetime import datetime +from src.agent.logic.validation import validate_required_fields +from src.api.schemas.anatel_schemas import ( + DataSourceEvent, ReasonCode +) + +@pytest.fixture +def valid_ticket_payload(): + return { + "source_system": "test", + "ingested_at": datetime.now(), + "correlation_id": "123", + "data_source_ticket": { + "data_source_ticket_id": "TICKET-123", + "status": "OPEN", + "action_type": "TEST", + "area_fechamento": "AREA", + "site_fechamento": "SITE", + "grupo_skill_responsavel": "SKILL", + "canal_origem": "WEB" + }, + "anatel": { + "anatel_complaint_id": "COMP-1", + "protocol": "PROT-123", + "opened_at": datetime.now(), + "due_at": datetime.now(), + "title": "Title", + "motive": "Motive", + "submotive": "Submotive", + "description": "Desc", + "service": "Service", + "first_service": "FS", + "modality": "Modality" + }, + "crm": { + "siebel_sr_number": "SR-1", + "customer_segment": "SEG", + "odc_customer": False, + "contumaz_customer": False + }, + "customer": { + "customer_name": "Test Name", + "cpf_cnpj": "12345678909", + "phones": ["11999999999"], + "address": { + "cep": "00000000", + "street": "Rua", + "number": "1", + "neighborhood": "Bairro", + "city": "City", + "state": "SP" + } + }, + "routing_inputs": { + "acionamentos_count": 0, + "routing_group_skill": "G", + "routing_modalidade": "M", + "routing_priority": "P", + "speech_expected": True + }, + "attachments": {"consumer_attachments": [], "data_source_attachments": []}, + "subscriber": { + "cpf_cnpj": "12345678909", + "customer_name": "Subscriber Name", + "contact_phone": "11999999999", + "contact_name": "Contact Name" + }, + "history_ids": [], + "raw_print_fields": {} + } + +def test_validate_required_fields_success(valid_ticket_payload): + ticket = DataSourceEvent(**valid_ticket_payload) + result = validate_required_fields(ticket) + assert result.is_valid is True + assert result.error_response is None + +def test_validate_missing_cpf(valid_ticket_payload): + valid_ticket_payload["customer"]["cpf_cnpj"] = "" + valid_ticket_payload["subscriber"]["cpf_cnpj"] = "" + ticket = DataSourceEvent(**valid_ticket_payload) + result = validate_required_fields(ticket) + + assert result.is_valid is False + assert result.error_response["status"] == 400 + messages = result.error_response["detail"]["messages"] + assert any(m["code"] == ReasonCode.MISSING_CPF.value for m in messages) + +def test_validate_missing_multiple_fields(valid_ticket_payload): + valid_ticket_payload["customer"]["cpf_cnpj"] = "" + valid_ticket_payload["subscriber"]["cpf_cnpj"] = "" + valid_ticket_payload["anatel"]["title"] = "" + valid_ticket_payload["data_source_ticket"]["data_source_ticket_id"] = "" + + ticket = DataSourceEvent(**valid_ticket_payload) + result = validate_required_fields(ticket) + + assert result.is_valid is False + messages = result.error_response["detail"]["messages"] + codes = [m["code"] for m in messages] + + assert ReasonCode.MISSING_CPF.value in codes + assert ReasonCode.MISSING_TITLE.value in codes + assert ReasonCode.MISSING_IDS.value in codes + + +def test_validate_missing_anatel_motive(valid_ticket_payload): + """Valida rejeição por falta de motivo da Anatel.""" + valid_ticket_payload["anatel"]["motive"] = " " + ticket = DataSourceEvent(**valid_ticket_payload) + result = validate_required_fields(ticket) + assert result.error_response["detail"]["messages"][0]["code"] == ReasonCode.MISSING_MOTIVE.value + +# def test_validate_missing_opened_at(valid_ticket_payload): +# """Valida rejeição por falta de data de abertura.""" +# valid_ticket_payload["anatel"]["opened_at"] = None +# ticket = DataSourceEvent(**valid_ticket_payload) +# result = validate_required_fields(ticket) +# assert result.error_response["detail"]["messages"][0]["code"] == ReasonCode.MISSING_OPENED_AT.value diff --git a/tests_original_develop/verify_prompts.py b/tests_original_develop/verify_prompts.py new file mode 100644 index 0000000..a0d86f7 --- /dev/null +++ b/tests_original_develop/verify_prompts.py @@ -0,0 +1,53 @@ +import os +from pathlib import Path + +from src.core.prompt_manager import get_prompt, _prompt_cache +from src.core.config import settings +import time + +def test_prompt_manager(): + print("Starting Prompt Manager Verification...") + + local_p = "LOCAL PROMPT" + prompt_name = "test_prompt" + + # 1. Test Local Fallback (Langfuse Disabled) + settings.USE_LANGFUSE_PROMPTS = False + p = get_prompt(prompt_name, local_p) + assert p == local_p + print("PASS: Local fallback when USE_LANGFUSE_PROMPTS=False") + + # 2. Test Fetching (Langfuse Enabled) - This will likely fail or return fallback if not configured + settings.USE_LANGFUSE_PROMPTS = True + print("\nTesting with USE_LANGFUSE_PROMPTS=True...") + # We expect this to either work (if keys are valid and prompt exists) + # or fall back gracefully (if keys are invalid or prompt doesn't exist) + p = get_prompt(prompt_name, local_p) + print(f"Result for '{prompt_name}': {p[:50]}...") + + # 3. Test Cache + print("\nTesting TTL Cache...") + # Manually inject into cache + _prompt_cache[prompt_name] = ("CACHED CONTENT", time.time()) + + # TTL is 600 by default. This should return cached content. + p = get_prompt(prompt_name, local_p) + assert p == "CACHED CONTENT" + print("PASS: Cache hit before TTL expiry") + + # Test TTL Expiry + print("\nTesting Cache Expiry...") + _prompt_cache[prompt_name] = ("EXPIRED CONTENT", time.time() - 1000) + # Should attempt to fetch (and likely fall back to local if test_prompt doesn't exist in Langfuse) + p = get_prompt(prompt_name, local_p) + # If fetch fails, it returns local_p. If it hits Langfuse, it returns that. + # The important part is that it didn't return "EXPIRED CONTENT". + assert p != "EXPIRED CONTENT" + print(f"PASS: Cache expiry handled (Returned: {p})") + +if __name__ == "__main__": + try: + test_prompt_manager() + print("\nAll logical checks passed!") + except Exception as e: + print(f"\nVerification FAILED: {e}") diff --git a/tools/__pycache__/validate_parity.cpython-313.pyc b/tools/__pycache__/validate_parity.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ced5a459f2979b291a78ad8eaecf501bb66b69da GIT binary patch literal 5973 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 literal 0 HcmV?d00001 diff --git a/tools/validate_parity.py b/tools/validate_parity.py new file mode 100644 index 0000000..2c81565 --- /dev/null +++ b/tools/validate_parity.py @@ -0,0 +1,157 @@ +from pathlib import Path + +REQUIRED = [ + 'app/main.py', + 'app/workflows/agent_graph.py', + 'app/workflows/backoffice_native_runtime.py', + 'app/agents/backoffice_agent.py', + 'src/agent/nodes/fetch_ticket_node.py', + 'src/agent/nodes/validation_node.py', + 'src/agent/nodes/imdb_enrichment_node.py', + 'src/agent/nodes/speech_enrichment_node.py', + 'src/agent/nodes/knowledge_base_enrichment_node.py', + 'src/agent/nodes/treatment_decision_node.py', + 'src/agent/nodes/siebel_sr_opening_node.py', + 'src/agent/nodes/emulator/generate_response_node.py', + 'src/components/clients/imdb_client.py', + 'src/components/clients/siebel_client.py', + 'src/components/clients/speech_analytics_client.py', + 'src/components/clients/tais_kb_client.py', + 'src/components/clients/abrt_client.py', + 'src/components/clients/portability_client.py', + 'src/agent/local_prompts/canceling_analysis.py', + 'src/agent/local_prompts/tim_complaint_analysis.py', + 'src/agent/local_prompts/treatment_decision_prompt.py', + 'src/agent/local_prompts/emulator/response_emulator_generation.py', + 'config/agents/backoffice_anatel/guardrails.yaml', + 'config/agents/backoffice_anatel/judges.yaml', + 'legacy_reference_disabled/original_develop/src_agent_graphs/main_graph.py', + 'legacy_reference_disabled/original_develop/src_agent_graphs/emulator_graph.py', +] + +FORBIDDEN_ACTIVE_TEXT = [ + 'from src.agent.graphs', + 'import src.agent.graphs', + 'from src.api.executors', + 'import src.api.executors', + 'include_router(original_', + 'GRAPH_FACTORIES', + 'get_or_create_graph("checklist")', +] + +missing = [p for p in REQUIRED if not Path(p).exists()] +if missing: + print('MISSING') + for p in missing: + print('-', p) + raise SystemExit(1) + +for py in [p for p in Path('app').rglob('*.py') if '__pycache__' not in str(p)]: + text = py.read_text(errors='ignore') + for token in FORBIDDEN_ACTIVE_TEXT: + if token in text: + print(f'FORBIDDEN ACTIVE TOKEN in {py}: {token}') + raise SystemExit(1) + +main = Path('app/main.py').read_text() +needles = [ + 'BackofficeNativeRuntime', + 'execute_workflow(', + 'backoffice_checklist', + 'backoffice_response_emulator', + 'legacy_router_registration": False', +] +for n in needles: + if n not in main: + print('MISSING MAIN NEEDLE:', n) + raise SystemExit(1) + + +# Guardrails must be bound from the backoffice agent profile, not only from global config. +runtime = Path('app/workflows/backoffice_native_runtime.py').read_text(errors='ignore') +for needle in [ + '_resolve_agent_profile("backoffice_anatel")', + 'self.agent_profile["guardrails_config_path"]', + '_build_guardrail_pipeline(', + 'active_input_rails', + 'active_output_rails', + 'guardrails_config_path', +]: + if needle not in runtime: + print('MISSING GUARDRAIL BINDING NEEDLE:', needle) + raise SystemExit(1) + +agent_guardrails = Path('config/agents/backoffice_anatel/guardrails.yaml').read_text(errors='ignore') +for rail_code in ['INPUT_SIZE', 'MSK', 'PINJ', 'TOX', 'DLEX_IN', 'RAGSEC', 'VLOOP', 'REVPREC', 'DLEX_OUT', 'OOS', 'CMP', 'AOFERTA']: + if rail_code not in agent_guardrails: + print('MISSING BACKOFFICE GUARDRAIL CODE:', rail_code) + raise SystemExit(1) + +print('OK: backoffice framework-native structural validation passed') +print('python_files=', len(list(Path('.').rglob('*.py')))) + +# IC/NOC/GRL observability must be framework-native in the backoffice runtime. +runtime = Path('app/workflows/backoffice_native_runtime.py').read_text(errors='ignore') +for needle in [ + '_safe_emit_ic', + '_safe_emit_noc', + '_safe_emit_grl', + '_bridge_legacy_ics', + 'IC.BACKOFFICE_WORKFLOW_STARTED', + 'IC.BACKOFFICE_WORKFLOW_COMPLETED', + 'NOC.001', + 'NOC.006', + 'NOC.009', + 'GRL.001', + 'GRL.002', + 'GRL.003', + 'GRL.004', + 'GRL.005', + 'GRL.006', + 'GRL.007', + 'GRL.008', + 'GRL.009', +]: + if needle not in runtime: + print('MISSING OBSERVABILITY NEEDLE:', needle) + raise SystemExit(1) + +# LLM provider must accept and default to OCI OpenAI-compatible. +core_cfg = Path('src/core/config.py').read_text(errors='ignore') +env_example = Path('.env.example').read_text(errors='ignore') +if 'oci_openai' not in core_cfg or 'LLM_PROVIDER=oci_openai' not in env_example: + print('MISSING OCI_OPENAI PROVIDER SUPPORT') + raise SystemExit(1) + +# Must not reference modules that do not exist in agent_framework(7).zip. +FORBIDDEN_FRAMEWORK_IMPORTS = [ + 'agent_framework.' + 'core', + 'agent_framework.' + 'components', + 'agent_framework.llm.' + 'factory', + 'agent_framework.observer.' + 'application_log', + 'agent_framework.observer.' + 'publishers', + 'agent_framework.observer.' + 'types', +] +for folder in ['app', 'src']: + for py in Path(folder).rglob('*.py'): + text = py.read_text(errors='ignore') + for token in FORBIDDEN_FRAMEWORK_IMPORTS: + if token in text: + print(f'FORBIDDEN FRAMEWORK IMPORT in {py}: {token}') + raise SystemExit(1) +print('OK: no forbidden agent_framework imports') + +# Backend must not implement/monkey-patch LLM generation telemetry (option B). +_generation_offenders = [] +for _folder in ['app', 'src']: + for _py in Path(_folder).rglob('*.py'): + _text = _py.read_text(errors='ignore') + for _pattern in ['with generation', 'def generation', 'observer.generation', 'import generation']: + if _pattern in _text: + _generation_offenders.append(f'{_py}: {_pattern}') +if _generation_offenders: + print('BACKEND_GENERATION_TELEMETRY_FORBIDDEN') + for _item in _generation_offenders: + print('-', _item) + raise SystemExit(1) +print('OK: no backend generation telemetry; LLM telemetry is delegated to agent_framework.llm.providers')