From cf91e1c3c1e0a581b063f90f7317068196019ea4 Mon Sep 17 00:00:00 2001 From: vrtornisiello Date: Mon, 30 Mar 2026 10:51:31 -0300 Subject: [PATCH 1/3] chore: remove chatbot models --- backend/apps/chatbot/models.py | 56 ---------------------------------- 1 file changed, 56 deletions(-) diff --git a/backend/apps/chatbot/models.py b/backend/apps/chatbot/models.py index 1f3a16dc0..e69de29bb 100644 --- a/backend/apps/chatbot/models.py +++ b/backend/apps/chatbot/models.py @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- -import uuid - -from django.db import models -from django.db.models import CheckConstraint, Q -from django.utils import timezone - -from backend.apps.account.models import Account - - -class Thread(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - account = models.ForeignKey(Account, on_delete=models.CASCADE) - title = models.TextField() - created_at = models.DateTimeField(auto_now_add=True) - deleted = models.BooleanField(default=False) - - -class MessagePair(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - thread = models.ForeignKey(Thread, on_delete=models.CASCADE) - model_uri = models.TextField() - user_message = models.TextField() - assistant_message = models.TextField(null=True) - error_message = models.TextField(null=True) - created_at = models.DateTimeField(auto_now_add=True) - events = models.JSONField(null=True) - - class Meta: - constraints = [ - CheckConstraint( - check=Q(assistant_message__isnull=False) ^ Q(error_message__isnull=False), - name="check_exactly_one_response", - ) - ] - - -class Feedback(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - message_pair = models.OneToOneField(MessagePair, on_delete=models.CASCADE, primary_key=False) - rating = models.SmallIntegerField(choices=[(0, "Bad"), (1, "Good")]) - comment = models.TextField(null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(null=True, blank=True) - sync_status = models.TextField( - choices=[("pending", "Pending"), ("success", "Success"), ("failed", "Failed")], - default="pending", - ) - synced_at = models.DateTimeField(null=True, blank=True) - - def user_update(self, data: dict[str, int | str]): - for attr, value in data.items(): - setattr(self, attr, value) - self.updated_at = timezone.now() - self.sync_status = "pending" - self.save() From ffd53d4f7ce47520fbd311dacdf923c886e255aa Mon Sep 17 00:00:00 2001 From: vrtornisiello Date: Mon, 30 Mar 2026 10:52:22 -0300 Subject: [PATCH 2/3] chore: remove chatbot files --- backend/apps/chatbot/admin.py | 41 -- backend/apps/chatbot/agent/__init__.py | 4 - backend/apps/chatbot/agent/prompts.py | 201 ------- backend/apps/chatbot/agent/react_agent.py | 305 ---------- backend/apps/chatbot/agent/tools.py | 580 ------------------- backend/apps/chatbot/agent/types.py | 4 - backend/apps/chatbot/apps.py | 3 - backend/apps/chatbot/authentication.py | 8 - backend/apps/chatbot/checks.py | 78 --- backend/apps/chatbot/feedback_sender.py | 112 ---- backend/apps/chatbot/mock/__init__.py | 270 --------- backend/apps/chatbot/mock/mock_data.py | 528 ----------------- backend/apps/chatbot/serializers.py | 41 -- backend/apps/chatbot/tests/__init__.py | 0 backend/apps/chatbot/tests/test_endpoints.py | 424 -------------- backend/apps/chatbot/tests/test_utils.py | 88 --- backend/apps/chatbot/urls.py | 19 +- backend/apps/chatbot/utils/__init__.py | 4 - backend/apps/chatbot/utils/gcloud.py | 47 -- backend/apps/chatbot/utils/stream.py | 163 ------ backend/apps/chatbot/views.py | 466 --------------- 21 files changed, 1 insertion(+), 3385 deletions(-) delete mode 100644 backend/apps/chatbot/admin.py delete mode 100644 backend/apps/chatbot/agent/__init__.py delete mode 100644 backend/apps/chatbot/agent/prompts.py delete mode 100644 backend/apps/chatbot/agent/react_agent.py delete mode 100644 backend/apps/chatbot/agent/tools.py delete mode 100644 backend/apps/chatbot/agent/types.py delete mode 100644 backend/apps/chatbot/authentication.py delete mode 100644 backend/apps/chatbot/checks.py delete mode 100644 backend/apps/chatbot/feedback_sender.py delete mode 100644 backend/apps/chatbot/mock/__init__.py delete mode 100644 backend/apps/chatbot/mock/mock_data.py delete mode 100644 backend/apps/chatbot/serializers.py delete mode 100644 backend/apps/chatbot/tests/__init__.py delete mode 100644 backend/apps/chatbot/tests/test_endpoints.py delete mode 100644 backend/apps/chatbot/tests/test_utils.py delete mode 100644 backend/apps/chatbot/utils/__init__.py delete mode 100644 backend/apps/chatbot/utils/gcloud.py delete mode 100644 backend/apps/chatbot/utils/stream.py delete mode 100644 backend/apps/chatbot/views.py diff --git a/backend/apps/chatbot/admin.py b/backend/apps/chatbot/admin.py deleted file mode 100644 index 3578af362..000000000 --- a/backend/apps/chatbot/admin.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -from django.contrib import admin - -from .models import Feedback, MessagePair, Thread - - -class ThreadAdmin(admin.ModelAdmin): - list_display = [field.name for field in Thread._meta.fields] - readonly_fields = list_display - search_fields = [ - "id", - "account__email", - ] - ordering = ["-created_at"] - - -class MessagePairAdmin(admin.ModelAdmin): - list_display = [field.name for field in MessagePair._meta.fields] - readonly_fields = list_display - search_fields = [ - "id", - "thread__id", - "user_message", - "assistant_message", - ] - ordering = ["-created_at"] - - -class FeedbackAdmin(admin.ModelAdmin): - list_display = [field.name for field in Feedback._meta.fields] - readonly_fields = list_display - search_fields = [ - "id", - "message_pair__id", - ] - ordering = ["-created_at"] - - -admin.site.register(Thread, ThreadAdmin) -admin.site.register(MessagePair, MessagePairAdmin) -admin.site.register(Feedback, FeedbackAdmin) diff --git a/backend/apps/chatbot/agent/__init__.py b/backend/apps/chatbot/agent/__init__.py deleted file mode 100644 index ef48ee31e..000000000 --- a/backend/apps/chatbot/agent/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- -from .react_agent import ReActAgent - -__all__ = ["ReActAgent"] diff --git a/backend/apps/chatbot/agent/prompts.py b/backend/apps/chatbot/agent/prompts.py deleted file mode 100644 index a4f4a67a2..000000000 --- a/backend/apps/chatbot/agent/prompts.py +++ /dev/null @@ -1,201 +0,0 @@ -# -*- coding: utf-8 -*- -SQL_AGENT_SYSTEM_PROMPT = """# Persona: Assistente de Pesquisa Base dos Dados -Você é um assistente de IA especializado na plataforma Base dos Dados (BD). Sua missão é ser um parceiro de pesquisa experiente, sistemático e transparente, guiando os usuários na construção de consultas SQL para buscar e analisar dados públicos brasileiros. - ---- - -# Ferramentas Disponíveis -Você tem acesso ao seguinte conjunto de ferramentas: - -- **search_datasets:** Para buscar datasets relacionados à pergunta do usuário. -- **get_dataset_details:** Para obter informações detalhadas sobre um dataset específico, incluindo a cobertura temporal e estrutura das tabelas. -- **execute_bigquery_sql:** Para executar consultas SQL **exploratórias e intermediárias** nas tabelas disponíveis. -- **decode_table_values:** Para decodificar valores codificados utilizando um dicionário de dados. - ---- - -# Uso Eficiente de Metadados (CRÍTICO) -Antes de executar qualquer consulta SQL, **SEMPRE** verifique os metadados retornados por `get_dataset_details`. - -## Cobertura Temporal -O campo `temporal_coverage` em cada tabela contém informações autoritativas sobre o período dos dados: - -- **Se `temporal_coverage.start` e `temporal_coverage.end` existirem:** - - Use esses valores diretamente - - **NÃO execute** `SELECT MIN(ano)`, `SELECT MAX(ano)` ou `SELECT DISTINCT ano` - -- **Se `temporal_coverage` mostrar valores null:** - - Para tabelas de dicionário: Elas não têm dimensão temporal - - Para outras tabelas: Execute uma consulta exploratória para verificar os anos disponíveis - - -Abordagem Correta (sem consulta SQL): -1. Chamei `get_dataset_details` para o dataset RAIS -2. Vi que a tabela "microdados_vinculos" tem `temporal_coverage: {"start": "1985", "end": "2024"}` -3. Resposta direta: "Os dados estão disponíveis de 1985 a 2024" - - - -Abordagem Correta (com consulta SQL): -1. Chamei `get_dataset_details` para o dataset RAIS -2. Vi que a tabela "microdados_vinculos" tem `temporal_coverage: {"start": null, "end": null}` -3. Executei: `SELECT MIN(ano), MAX(ano) FROM basedosdados.br_me_rais.microdados_vinculos` - - - -Abordagem Incorreta: -1. Chamei `get_dataset_details` -2. Ignorei o campo `temporal_coverage` -3. Executei: `SELECT MIN(ano), MAX(ano) FROM basedosdados.br_me_rais.microdados_vinculos` -4. Resultado: Consulta desnecessária que gasta recursos e tempo - - -## Valores Codificados -Muitas colunas usam códigos numéricos ou alfanuméricos para eficiência de armazenamento. - -**Identificando Valores Codificados:** -- Valores como "1", "2", "3" ou "A", "B", "C" em colunas categóricas -- Descrições de colunas mencionando "id", "código", "classificação", "tipo", etc. -- Exemplos: `id_municipio`, `tipo_vinculo` - -Sempre use `decode_table_values` para obter os significados reais antes de apresentar resultados ao usuário. - ---- - -# Regras de Execução (CRÍTICO) -1. Toda vez que você utilizar uma ferramenta, você **DEVE** escrever um **breve resumo** do seu raciocínio. -2. Toda vez que você escrever a resposta final para o usuário, você **DEVE** seguir as diretrizes listadas na seção "Resposta Final". -3. **NUNCA** desista na primeira vez em que receber uma mensagem de erro. Persista e tente outras abordagens, até conseguir elaborar uma resposta final para o usuário, seguindo as diretrizes listadas na seção "Guia Para Análise de Erros". -4. **NUNCA** retorne uma resposta em branco. -5. **Use consultas SQL intermediárias** para explorar os dados, mas **apresente a consulta final** sem executá-la. Caso o usuário solicite que você execute a consulta final, recuse educadamente. - ---- - -# Protocolo de Esclarecimento de Consulta (CRÍTICO) -1. **Avalie a Pergunta do Usuário:** Antes de usar qualquer ferramenta, determine se a pergunta é específica o suficiente para iniciar uma busca de dados. - - **Pergunta Específica (Exemplos):** "Qual foi o IDEB médio por estado em 2021?", "Número de nascidos vivos em São Paulo em 2020". - - **Pergunta Genérica (Exemplos):** "Dados sobre educação", "Me fale sobre saneamento básico". - -2. **Aja de Acordo:** - - **Se a pergunta for específica:** Prossiga diretamente para o "Protocolo de Busca". - - **Se a pergunta for genérica:** **NÃO USE NENHUMA FERRAMENTA**. Em vez disso, ajude o usuário a refinar a pergunta. Seja amigável, não diga ao usuário que a pergunta dele é genérica. Formule uma resposta que incentive a especificidade, abordando os seguintes pontos-chave para a análise de dados: - - **Tipo de informação:** Qual métrica ou dado específico o usuário busca? (ex: produção, consumo, preços, etc.) - - **Período de tempo:** Qual o recorte temporal de interesse? (ex: ano mais recente, últimos 5 anos, um ano específico) - - **Nível geográfico:** Qual a granularidade espacial necessária? (ex: Brasil, por estado, por município) - - **Finalidade (Opcional):** Entender o objetivo da pesquisa pode ajudar a refinar a busca e a gerar insights mais relevantes. - Para tornar a orientação mais concreta, **sempre** sugira 1 ou 2 exemplos de perguntas específicas e relevantes para o tema. - ---- - -# Dados Brasileiros Essenciais -Abaixo estão listadas algumas das principais fontes de dados disponíveis: - -- **IBGE**: Censo, demografia, pesquisas econômicas (`censo`, `pnad`, `pof`). -- **INEP**: Dados de educação (`ideb`, `censo escolar`, `enem`). -- **Ministério da Saúde (MS)**: Dados de saúde (`pns`, `sinasc`, `sinan`, `sim`). -- **Ministério da Economia (ME)**: Dados de emprego e economia (`rais`, `caged`). -- **Tribunal Superior Eleitoral (TSE)**: Dados eleitorais (`eleicoes`). -- **Banco Central do Brasil (BCB)**: Dados financeiros (`taxa selic`, `cambio`, `ipca`). - -Abaixo estão listados alguns padrões comumente encontrados nas fontes de dados: - -- **Geográfico**: `sigla_uf` (estado), `id_municipio` (município - código IBGE 7 dígitos). -- **Temporal**: `ano` (ano), campo `temporal_coverage` dos metadados. -- **Identificadores**: `id_*`, `codigo_*`, `sigla_*`. -- **Valores Codificados**: Muitas colunas usam códigos para eficiência de armazenamento. Identifique-os pela descrição da coluna ou pelos valores (ex: 1, 2, 3). **Sempre** utilize a ferramenta `decode_table_values` para decodificá-los antes de apresentar resultados. - ---- - -# Protocolo de Busca -Você **DEVE** seguir este funil de busca hierárquico. Comece toda busca com uma única palavra-chave. - -- **Nível 1: Palavra-Chave Única (Tente Primeiro)** - 1. **Nome do Conjunto de Dados:** Se a consulta mencionar um nome conhecido ("censo", "rais", "enem"). - 2. **Acrônimo da Organização:** Se uma organização for relevante ("ibge", "inep", "tse"). - 3. **Tema Central (Português):** Um tema amplo e comum ("educacao", "saude", "economia", "emprego"). - -- **Nível 2: Palavras-Chave Alternativas (Se Nível 1 Falhar)** - - **Sinônimos:** Tente um sinônimo em português ("ensino" para "educacao", "trabalho" para "emprego"). - - **Conceitos Mais Amplos:** Use um termo mais geral ("social", "demografia", "infraestrutura"). - - **Termos em Inglês**: Como último recurso para palavras-chave únicas, tente termos em inglês ("health", "education"). - -- **Nível 3: Múltiplas Palavras-Chave (Último Recurso)** -Use 2-3 palavras-chave apenas se todas as buscas com palavra-chave única falharem ("saude ms", "censo municipio"). - - -Usuário: Como foi o desempenho em matemática dos alunos no brasil nos últimos anos? - -A pergunta é sobre desempenho de alunos. A organização INEP é a fonte mais provável para dados educacionais. Portanto, minha hipótese é que os dados estão em um dataset do INEP. Vou começar minha busca usando o acrônimo da organização como palavra-chave única. - - ---- - -# Protocolo de Consultas SQL (CRÍTICO) -Você deve distinguir claramente entre dois tipos de consultas: - -## Consultas Intermediárias (EXECUTAR) -- São auxiliares para entender os dados -- Geralmente retornam pequenas quantidades de dados (use LIMIT) -- Ajudam a construir a consulta final corretamente - -Use `execute_bigquery_sql` para consultas exploratórias: -- Explorar a estrutura e conteúdo das tabelas -- Examinar valores únicos de colunas: `SELECT DISTINCT coluna FROM tabela LIMIT 20` -- Contar registros: `SELECT COUNT(*) FROM tabela WHERE ...` -- Ver exemplos de dados: `SELECT * FROM tabela LIMIT 5` -- Validar hipóteses sobre os dados -- Testar filtros e agregações - -## Consulta Final (NÃO EXECUTAR) -- Responde diretamente à pergunta do usuário -- É completa, otimizada e bem documentada -- Está pronta para ser executada pelo usuário - -A consulta que **responde diretamente à pergunta do usuário** deve ser: -- Construída com base nos aprendizados das consultas intermediárias -- **Apresentada ao usuário com comentários explicativos** -- **NUNCA executada** com `execute_bigquery_sql` - ---- - -# Protocolo SQL (BigQuery) -- **Referencie IDs completos:** Sempre use o ID completo da tabela: `projeto.dataset.tabela`. -- **Selecione colunas específicas:** Nunca use `SELECT *` na consulta final. Liste explicitamente as colunas que você precisa. -- **Priorize os dados mais recentes:** Se o usuário não especificar um intervalo de tempo: - 1. **Primeiro**, verifique `temporal_coverage.end` nos metadados da tabela obtidos por `get_dataset_details` - 2. Se disponível, use esse ano diretamente na query - 3. **Apenas se `temporal_coverage.end` for null ou vazio**, execute uma consulta exploratória -- **Ordene os resultados**: Use `ORDER BY` para apresentar os dados de forma lógica. -- **Read-only:** **NUNCA** inclua comandos `CREATE`, `ALTER`, `DROP`, `INSERT`, `UPDATE`, `DELETE`. -- **Adicione comentários na consulta final:** Utilize comentários SQL (`--`) para explicar cada seção importante. - ---- - -# Resposta Final -Ao redigir a resposta final, **não inclua o seu processo de raciocínio**. Construa um texto explicativo e fluido, porém **conciso**. Evite repetições e vá direto ao ponto. Sua resposta deve ser completa e fácil de entender, garantindo que os seguintes elementos sejam naturalmente integrados na ordem sugerida: - -1. Inicie a resposta com um resumo direto (2-3 frases) sobre o que a consulta SQL irá retornar e como ela responde à pergunta do usuário. - -2. Explique brevemente a origem e o escopo dos dados em 1-2 frases, incluindo o período de tempo e o nível geográfico consultado (ex: "Esta consulta busca dados do Censo Escolar de 2021, realizado pelo INEP, agregados por estado"). - -3. **Apresente a consulta SQL final completa**, formatada como um bloco de código markdown **com comentários inline concisos**. Os comentários devem: - - Usar linguagem simples e objetiva - - Ser breves e diretos (máximo 1 linha por comentário) - - Explicar apenas o essencial de cada seção (SELECT, FROM, WHERE, GROUP BY, ORDER BY, etc.) - - Exemplo: `-- Filtra para o ano de 2021` ao invés de `-- Aqui estamos filtrando os dados para incluir apenas o ano de 2021...` - -4. Após a consulta, forneça uma explicação em linguagem natural (3-5 frases) destacando apenas os aspectos **mais importantes** da query: - - Foque nas decisões principais (por que essa tabela, principais filtros, tipo de agregação) - - Não repita informações já claras nos comentários SQL - - Seja objetivo e evite redundância - -5. Conclua com **2-3 sugestões práticas** e diretas de como o usuário pode adaptar a consulta. Por exemplo: - - Modificar filtros (ex: alterar anos, estados, municípios) - - Adicionar novas dimensões de análise - - Combinar com outras tabelas para análises mais complexas - ---- - -# Guia Para Análise de Erros -- **Falhas na Busca**: Explique sua estratégia de palavras-chave, declare por que falhou (ex: "A busca por 'cnes' não retornou nenhum conjunto de dados") e descreva sua próxima tentativa com base no **Protocolo de Busca**. -- **Erros em Consultas Intermediárias**: Analise a mensagem de erro e ajuste a consulta. Estes erros são esperados e fazem parte do processo de exploração.""" # noqa: E501 diff --git a/backend/apps/chatbot/agent/react_agent.py b/backend/apps/chatbot/agent/react_agent.py deleted file mode 100644 index 4b3aa81b4..000000000 --- a/backend/apps/chatbot/agent/react_agent.py +++ /dev/null @@ -1,305 +0,0 @@ -# -*- coding: utf-8 -*- -from collections.abc import Callable -from typing import Annotated, AsyncIterator, Generic, Iterator, Literal, Sequence, Type, TypedDict - -from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage -from langchain_core.runnables import RunnableConfig, RunnableLambda -from langchain_core.tools import BaseTool, BaseToolkit -from langgraph.checkpoint.postgres import PostgresSaver -from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver -from langgraph.graph.message import add_messages -from langgraph.graph.state import CompiledStateGraph, StateGraph -from langgraph.managed import IsLastStep, RemainingSteps -from langgraph.prebuilt import ToolNode -from loguru import logger - -from .types import StateT - - -class ReActState(TypedDict): - messages: Annotated[list[BaseMessage], add_messages] - """Message list""" - - is_last_step: IsLastStep - """Flag indicating if the last step has been reached""" - - remaining_steps: RemainingSteps - """Number of remaining steps before reaching the steps limit""" - - -class ReActAgent(Generic[StateT]): - """A LangGraph ReAct Agent.""" - - agent_node = "agent" - tools_node = "tools" - start_hook_node = "start_hook" - - def __init__( - self, - model: BaseChatModel, - tools: Sequence[BaseTool] | BaseToolkit, - state_schema: Type[StateT] = ReActState, - start_hook: Callable[[StateT], dict] | None = None, - prompt: SystemMessage | str | None = None, - checkpointer: PostgresSaver | AsyncPostgresSaver | bool | None = None, - ): - if isinstance(tools, BaseToolkit): - self.tools = tools.get_tools() - else: - self.tools = tools - - if isinstance(prompt, str): - self.system_message = SystemMessage(prompt) - else: - self.system_message = prompt - - self.model = model.bind_tools(self.tools) - - if self.system_message: - self.model_runnable = (lambda messages: [self.system_message] + messages) | self.model - else: - self.model_runnable = self.model - - self.checkpointer = checkpointer - - self.graph = self._compile(state_schema, start_hook) - - def _call_model(self, state: StateT, config: RunnableConfig) -> dict[str, list[BaseMessage]]: - """Calls the LLM on a message list. - - Args: - state (StateT): The graph state. - config (RunnableConfig): A config to use when calling the LLM. - - Returns: - dict[str, list[BaseMessage]]: The updated message list. - """ - messages = state["messages"] - is_last_step = state["is_last_step"] - remaining_steps = state["remaining_steps"] - - response: AIMessage = self.model_runnable.invoke(messages, config) - - if not response.content and not response.tool_calls: - logger.warning("[CHATBOT] Empty model response, skipping message list update") - return {"messages": []} - - if is_last_step and response.tool_calls or remaining_steps < 2 and response.tool_calls: - return { - "messages": [ - AIMessage( - id=response.id, - content=( - "Desculpe, não consegui encontrar uma resposta para a sua pergunta. " - "Por favor, tente reformulá-la ou pergunte algo diferente." - ), - ) - ] - } - - return {"messages": [response]} - - async def _acall_model( - self, state: StateT, config: RunnableConfig - ) -> dict[str, list[BaseMessage]]: - """Asynchronously calls the LLM on a message list. - - Args: - state (StateT): The graph state. - config (RunnableConfig): A config to use when calling the LLM. - - Returns: - dict[str, list[BaseMessage]]: The updated message list. - """ - messages = state["messages"] - is_last_step = state["is_last_step"] - remaining_steps = state["remaining_steps"] - - response: AIMessage = await self.model_runnable.ainvoke(messages, config) - - if not response.content and not response.tool_calls: - logger.warning("[CHATBOT] Empty model response, skipping message list update") - return {"messages": []} - - if is_last_step and response.tool_calls or remaining_steps < 2 and response.tool_calls: - return { - "messages": [ - AIMessage( - id=response.id, - content=( - "Desculpe, não consegui encontrar uma resposta para a sua pergunta. " - "Por favor, tente reformulá-la ou pergunte algo diferente." - ), - ) - ] - } - - return {"messages": [response]} - - def _compile( - self, state_schema: Type[StateT], start_hook: Callable[[StateT], dict] | None - ) -> CompiledStateGraph: - """Compiles the state graph into a LangChain Runnable. - - Args: - state_schema (Type[StateT]): The state graph schema. - start_hook (Callable[[StateT], dict] | None): An optional node to add before the agent node. - Useful for managing long message histories (e.g., message trimming, summarization, etc.). - Must be a callable or a runnable that takes in current graph state and returns a state update. - - Returns: - CompiledStateGraph: The compiled state graph. - """ # noqa: E501 - graph = StateGraph(state_schema) - - graph.add_node(self.agent_node, RunnableLambda(self._call_model, self._acall_model)) - graph.add_node(self.tools_node, ToolNode(self.tools)) - - if start_hook is not None: - graph.add_node("start_hook", start_hook) - graph.add_edge("start_hook", self.agent_node) - entrypoint = "start_hook" - else: - entrypoint = self.agent_node - - graph.set_entry_point(entrypoint) - graph.add_edge(self.tools_node, self.agent_node) - graph.add_conditional_edges(self.agent_node, _should_continue) - - # The checkpointer is ignored by default when the graph is used as a subgraph - # For more information, visit https://langchain-ai.github.io/langgraph/how-tos/subgraph-persistence - # If you want to persist the subgraph state between runs, you must use checkpointer=True - # For more information, visit https://github.com/langchain-ai/langgraph/issues/3020 - return graph.compile(self.checkpointer) - - def invoke(self, message: str, config: RunnableConfig | None = None) -> StateT: - """Runs the compiled graph. - - Args: - message (str): The input message. - config (RunnableConfig | None, optional): The configuration. Defaults to `None`. - - Returns: - StateT: The last output of the graph run. - """ - message = HumanMessage(content=message.strip()) - - response = self.graph.invoke( - input={"messages": [message]}, - config=config, - ) - - return response - - async def ainvoke(self, message: str, config: RunnableConfig | None = None) -> StateT: - """Asynchronously runs the compiled graph. - - Args: - message (str): The input message. - config (RunnableConfig | None, optional): The configuration. Defaults to `None`. - - Returns: - StateT: The last output of the graph run. - """ - message = HumanMessage(content=message.strip()) - - response = await self.graph.ainvoke( - input={"messages": [message]}, - config=config, - ) - - return response - - def stream( - self, - message: str, - config: RunnableConfig | None = None, - stream_mode: list[str] | None = None, - ) -> Iterator[dict | tuple]: - """Stream graph steps. - - Args: - message (str): The input message. - config (RunnableConfig | None, optional): Optional configuration for the agent execution. Defaults to `None`. - stream_mode (list[str] | None, optional): The mode to stream output. See the LangGraph streaming guide in - https://langchain-ai.github.io/langgraph/how-tos/streaming for more details. Defaults to `None`. - - Yields: - dict|tuple: The output for each step in the graph. Its type, shape and content depends on the `stream_mode` arg. - """ # noqa: E501 - message = message.strip() - - message = HumanMessage(content=message) - - for chunk in self.graph.stream( - input={"messages": [message]}, - config=config, - stream_mode=stream_mode, - ): - yield chunk - - async def astream( - self, - message: str, - config: RunnableConfig | None = None, - stream_mode: list[str] | None = None, - ) -> AsyncIterator[dict | tuple]: - """Asynchronously stream graph steps. - - Args: - message (str): The input message. - config (RunnableConfig | None, optional): Optional configuration for the agent execution. Defaults to `None`. - stream_mode (list[str] | None, optional): The mode to stream output. See the LangGraph streaming guide in - https://langchain-ai.github.io/langgraph/how-tos/streaming for more details. Defaults to `None`. - - Yields: - dict|tuple: The output for each step in the graph. Its type, shape and content depends on the `stream_mode` arg. - """ # noqa: E501 - message = message.strip() - - message = HumanMessage(content=message) - - async for chunk in self.graph.astream( - input={"messages": [message]}, - config=config, - stream_mode=stream_mode, - ): - yield chunk - - # Unfortunately, there is no clean way to delete an agent's memory - # except by deleting its checkpoints, as noted in this github discussion: - # https://github.com/langchain-ai/langgraph/discussions/912 - def clear_thread(self, thread_id: str): - """Deletes all checkpoints for a given thread. - - Args: - thread_id (str): The thread unique identifier. - """ - if self.checkpointer is not None: - self.checkpointer.delete_thread(thread_id) - - async def aclear_thread(self, thread_id: str): - """Asynchronously deletes all checkpoints for a given thread. - - Args: - thread_id (str): The thread unique identifier. - """ - if self.checkpointer is not None: - await self.checkpointer.adelete_thread(thread_id) - - -def _should_continue(state: StateT) -> Literal["tools", "__end__"]: - """Routes to the tools node if the last message has any tool calls. - Otherwise, routes to the message pruning node. - - Args: - state (StateT): The graph state. - - Returns: - str: The next node to route to. - """ - last_message = state["messages"][-1] - if hasattr(last_message, "tool_calls") and len(last_message.tool_calls) > 0: - return "tools" - return "__end__" diff --git a/backend/apps/chatbot/agent/tools.py b/backend/apps/chatbot/agent/tools.py deleted file mode 100644 index ad7343ead..000000000 --- a/backend/apps/chatbot/agent/tools.py +++ /dev/null @@ -1,580 +0,0 @@ -# -*- coding: utf-8 -*- -import inspect -import json -from collections.abc import Callable -from functools import wraps -from typing import Any, Literal, Self - -import httpx -from django.conf import settings -from google.api_core.exceptions import GoogleAPICallError -from google.cloud import bigquery as bq -from langchain_core.runnables import RunnableConfig -from langchain_core.tools import BaseTool, tool -from pydantic import BaseModel, model_validator - -from backend.apps.chatbot.utils.gcloud import get_bigquery_client - -# HTTPX Default Timeout -TIMEOUT = 5.0 - -# HTTPX Read Timeout -READ_TIMEOUT = 60.0 - -# Maximum number of datasets returned on search -PAGE_SIZE = 10 - -# 10GB limit for other queries -LIMIT_BIGQUERY_QUERY = 10 * 10**9 - -# URL for searching datasets -SEARCH_URL = f"{settings.BACKEND_URL}/search/" - -# URL for fetching dataset details -GRAPHQL_URL = f"{settings.BACKEND_URL}/graphql" - -# URL for fetching usage guides -BASE_USAGE_GUIDE_URL = "https://raw.githubusercontent.com/basedosdados/website/refs/heads/main/next/content/userGuide/pt" - -# GraphQL query for fetching dataset details -DATASET_DETAILS_QUERY = """ -query getDatasetDetails($id: ID!) { - allDataset(id: $id, first: 1) { - edges { - node { - id - name - slug - description - organizations { - edges { - node { - name - slug - } - } - } - themes { - edges { - node { - name - } - } - } - tags { - edges { - node { - name - } - } - } - tables { - edges { - node { - id - name - slug - description - temporalCoverage - cloudTables { - edges { - node { - gcpProjectId - gcpDatasetId - gcpTableId - } - } - } - columns { - edges { - node { - id - name - description - bigqueryType { - name - } - } - } - } - } - } - } - } - } - } -} -""" - - -class GoogleAPIError: - """Constants for expected Google API error types.""" - - BYTES_BILLED_LIMIT_EXCEEDED = "bytesBilledLimitExceeded" - NOT_FOUND = "notFound" - - -class Column(BaseModel): - """Represents a column in a BigQuery table with metadata.""" - - name: str - type: str - description: str | None - - -class Table(BaseModel): - """Represents a BigQuery table with its columns and metadata.""" - - id: str - gcp_id: str | None - name: str - slug: str | None - description: str | None - temporal_coverage: dict[str, str | None] - columns: list[Column] - - -class DatasetOverview(BaseModel): - """Basic dataset information without table details.""" - - id: str - name: str - slug: str | None - description: str | None - tags: list[str] - themes: list[str] - organizations: list[str] - - -class Dataset(DatasetOverview): - """Complete dataset information including all tables and columns.""" - - tables: list[Table] - usage_guide: str | None - - -class ErrorDetails(BaseModel): - "Error response format." - - error_type: str | None = None - message: str - instructions: str | None = None - - -class ToolError(Exception): - """Custom exception for tool-specific errors.""" - - def __init__( - self, message: str, error_type: str | None = None, instructions: str | None = None - ): - super().__init__(message) - self.error_type = error_type - self.instructions = instructions - - -class ToolOutput(BaseModel): - """Tool output response format.""" - - status: Literal["success", "error"] - results: Any | None = None - error_details: ErrorDetails | None = None - - @model_validator(mode="after") - def check_results_or_error(self) -> Self: - if (self.results is None) ^ (self.error_details is None): - return self - raise ValueError("Only one of 'results' or 'error_details' should be set") - - -def handle_tool_errors( - _func: Callable[..., Any] | None = None, - *, - instructions: dict[str, str] = {}, -) -> Callable[..., Any]: - """Decorator that catches errors in a tool function and returns them as structured JSON. - - Args: - _func (Callable[..., Any] | None, optional): Function to wrap. - Set automatically when used as a decorator. Defaults to None. - instructions (dict[str, str], optional): Maps known error reasons - from Google API to recovery instructions. If a reason matches, - the instruction is added to the error JSON. - - Returns: - Callable[..., Any]: Wrapped function that returns the tool result on success - or structured error JSON on failure. - """ - - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - @wraps(func) - def wrapper(*args, **kwargs) -> Any: - try: - return func(*args, **kwargs) - except GoogleAPICallError as e: - if e.errors: - reason = e.errors[0].get("reason") - message = e.errors[0].get("message", str(e)) - - error_details = ErrorDetails( - error_type=reason, message=message, instructions=instructions.get(reason) - ) - except ToolError as e: - error_details = ErrorDetails( - error_type=e.error_type, message=str(e), instructions=e.instructions - ) - except Exception as e: - error_details = ErrorDetails(message=f"Unexpected error: {e}") - - tool_output = ToolOutput(status="error", error_details=error_details).model_dump( - exclude_none=True - ) - return json.dumps(tool_output, ensure_ascii=False, indent=2) - - return wrapper - - if _func is None: - return decorator - - return decorator(_func) - - -@tool -@handle_tool_errors -def search_datasets(query: str) -> str: - """Search for datasets in Base dos Dados using keywords. - - CRITICAL: Use individual KEYWORDS only, not full sentences. The search engine uses Elasticsearch. - - Args: - query (str): 2-3 keywords maximum. Use Portuguese terms, organization acronyms, or dataset acronyms. - Good Examples: "censo", "educacao", "ibge", "inep", "rais", "saude" - Avoid: "Brazilian population data by municipality" - - Returns: - str: JSON array of datasets. If empty/irrelevant results, try different keywords. - - Strategy: Start with broad terms like "censo", "ibge", "inep", "rais", then get specific if needed. - Next step: Use `get_dataset_details()` with returned dataset IDs. - """ # noqa: E501 - with httpx.Client() as client: - response = client.get( - url=SEARCH_URL, - params={"contains": "tables", "q": query, "page_size": PAGE_SIZE}, - timeout=httpx.Timeout(TIMEOUT, read=READ_TIMEOUT), - ) - - response.raise_for_status() - data: dict = response.json() - - datasets = data.get("results", []) - - overviews = [] - - for dataset in datasets: - dataset_overview = DatasetOverview( - id=dataset["id"], - name=dataset["name"], - slug=dataset.get("slug"), - description=dataset.get("description"), - tags=[tag["name"] for tag in dataset.get("tags", [])], - themes=[theme["name"] for theme in dataset.get("themes", [])], - organizations=[org["name"] for org in dataset.get("organizations", [])], - ) - overviews.append(dataset_overview.model_dump()) - - tool_output = ToolOutput(status="success", results=overviews).model_dump(exclude_none=True) - return json.dumps(tool_output, ensure_ascii=False, indent=2) - - -@tool -@handle_tool_errors -def get_dataset_details(dataset_id: str) -> str: - """Get comprehensive details about a specific dataset including all tables and columns. - - Use AFTER `search_datasets()` to understand data structure before writing queries. - - Args: - dataset_id (str): Dataset ID obtained from `search_datasets()`. - This is typically a UUID-like string, not the human-readable name. - - Returns: - str: JSON object with complete dataset information, including: - - Basic metadata (name, description, tags, themes, organizations) - - tables: Array of all tables in the dataset with: - - gcp_id: Full BigQuery table reference (`project.dataset.table`) - - columns: All column names, types, and descriptions - - temporal coverage: Authoritative temporal coverage for the table - - table descriptions explaining what each table contains - - usage_guide: Provide key information and best practices for using the dataset. - - Next step: Use `execute_bigquery_sql()` to execute queries. - """ # noqa: E501 - with httpx.Client() as client: - response = client.post( - url=GRAPHQL_URL, - json={ - "query": DATASET_DETAILS_QUERY, - "variables": {"id": dataset_id}, - }, - timeout=httpx.Timeout(TIMEOUT, read=READ_TIMEOUT), - ) - - response.raise_for_status() - data: dict[str, dict[str, dict]] = response.json() - - all_datasets = data.get("data", {}).get("allDataset") or {} - dataset_edges = all_datasets.get("edges", []) - - if not dataset_edges: - raise ToolError( - message=f"Dataset {dataset_id} not found", - error_type="DATASET_NOT_FOUND", - instructions="Verify the dataset ID from `search_datasets` results", - ) - - dataset = dataset_edges[0]["node"] - - dataset_id = dataset["id"] - dataset_name = dataset["name"] - dataset_slug = dataset.get("slug") - dataset_description = dataset.get("description") - - # Tags - dataset_tags = [] - - for edge in dataset.get("tags", {}).get("edges", []): - if tag := edge.get("node", {}).get("name"): - dataset_tags.append(tag) - - # Themes - dataset_themes = [] - - for edge in dataset.get("themes", {}).get("edges", []): - if theme := edge.get("node", {}).get("name"): - dataset_themes.append(theme) - - # Organizations - dataset_organizations = [] - - for edge in dataset.get("organizations", {}).get("edges", []): - if org := edge.get("node", {}).get("name"): - dataset_organizations.append(org) - - # Tables - dataset_tables = [] - gcp_dataset_id = None - - for edge in dataset.get("tables", {}).get("edges", []): - table = edge["node"] - - table_id = table["id"] - table_name = table["name"] - table_slug = table.get("slug") - table_description = table.get("description") - table_temporal_coverage = table.get("temporalCoverage") - - cloud_table_edges = table["cloudTables"]["edges"] - if cloud_table_edges: - cloud_table = cloud_table_edges[0]["node"] - gcp_project_id = cloud_table["gcpProjectId"] - gcp_dataset_id = gcp_dataset_id or cloud_table["gcpDatasetId"] - gcp_table_id = cloud_table["gcpTableId"] - table_gcp_id = f"{gcp_project_id}.{gcp_dataset_id}.{gcp_table_id}" - else: - table_gcp_id = None - - table_columns = [] - for edge in table["columns"]["edges"]: - column = edge["node"] - table_columns.append( - Column( - name=column["name"], - type=column["bigqueryType"]["name"], - description=column.get("description"), - ) - ) - - dataset_tables.append( - Table( - id=table_id, - gcp_id=table_gcp_id, - name=table_name, - slug=table_slug, - description=table_description, - columns=table_columns, - temporal_coverage=table_temporal_coverage, - ) - ) - - # Fetch usage guide - usage_guide = None - - if gcp_dataset_id is not None: - filename = gcp_dataset_id.replace("_", "-") - - with httpx.Client() as client: - response = client.get( - url=f"{BASE_USAGE_GUIDE_URL}/{filename}.md", - timeout=httpx.Timeout(TIMEOUT, read=READ_TIMEOUT), - ) - - if response.status_code == httpx.codes.OK: - usage_guide = response.text.strip() - - dataset = Dataset( - id=dataset_id, - name=dataset_name, - slug=dataset_slug, - description=dataset_description, - tags=dataset_tags, - themes=dataset_themes, - organizations=dataset_organizations, - tables=dataset_tables, - usage_guide=usage_guide, - ).model_dump() - - tool_output = ToolOutput(status="success", results=dataset).model_dump(exclude_none=True) - return json.dumps(tool_output, ensure_ascii=False, indent=2) - - -@tool -@handle_tool_errors( - instructions={ - GoogleAPIError.BYTES_BILLED_LIMIT_EXCEEDED: "Add WHERE filters or select fewer columns." - } -) -def execute_bigquery_sql(sql_query: str, config: RunnableConfig) -> str: - """Execute a SQL query against BigQuery tables from the Base dos Dados database. - - Use AFTER identifying the right datasets and understanding tables structure. - It includes a 10GB processing limit for safety. - - Args: - sql_query (str): Standard GoogleSQL query. Must reference - tables using their full `gcp_id` from `get_dataset_details()`. - - Best practices: - - Use fully qualified names: `project.dataset.table` - - Select only needed columns, avoid `SELECT *` - - Add `LIMIT` for exploration - - Filter early with `WHERE` clauses - - Order by relevant columns - - Never use DDL/DML commands - - Use appropriate data types in comparisons - - Returns: - str: Query results as JSON array. Empty results return "[]". - """ # noqa: E501 - client = get_bigquery_client() - - job_config = bq.QueryJobConfig(dry_run=True, use_query_cache=False) - dry_run_query_job = client.query(sql_query, job_config=job_config) - statement_type = dry_run_query_job.statement_type - - if statement_type != "SELECT": - raise ToolError( - message=f"Query aborted: Statement {statement_type} is forbidden.", - error_type="FORBIDDEN_STATEMENT", - instructions="Your access is strictly read-only. Use only SELECT statements.", - ) - - labels = { - "thread_id": config.get("configurable", {}).get("thread_id", "unknown"), - "user_id": config.get("configurable", {}).get("user_id", "unknown"), - "tool_name": inspect.currentframe().f_code.co_name, - } - - job_config = bq.QueryJobConfig(maximum_bytes_billed=LIMIT_BIGQUERY_QUERY, labels=labels) - query_job = client.query(sql_query, job_config=job_config) - - rows = query_job.result() - results = [dict(row) for row in rows] - - tool_output = ToolOutput(status="success", results=results).model_dump(exclude_none=True) - return json.dumps(tool_output, ensure_ascii=False, default=str) - - -@tool -@handle_tool_errors( - instructions={GoogleAPIError.NOT_FOUND: ("Dictionary table not found for this dataset.")} -) -def decode_table_values( - table_gcp_id: str, - config: RunnableConfig, - column_name: str | None = None, -) -> str: - """Decode coded values from a table. - - Use when column values appear to be codes (e.g., 1,2,3 or A,B,C). - Many datasets use codes for storage efficiency. This tool provides - the authoritative meanings of these codes. - - Args: - table_gcp_id (str): Full BigQuery table reference. - column_name (str | None, optional): Column with coded values. If `None`, - all columns will be used. Defaults to `None`. - - Returns: - str: JSON array with chave (code) and valor (meaning) mappings. - """ - # noqa: E501 - try: - project_name, dataset_name, table_name = table_gcp_id.split(".") - except ValueError: - raise ToolError( - message=f"Invalid table reference: '{table_gcp_id}'", - error_type="INVALID_TABLE_REFERENCE", - instructions="Provide a valid table reference in the format `project.dataset.table`", - ) - - client = get_bigquery_client() - - dataset_id = f"{project_name}.{dataset_name}" - dict_table_id = f"{dataset_id}.dicionario" - - search_query = f""" - SELECT nome_coluna, chave, valor - FROM {dict_table_id} - WHERE id_tabela = '{table_name}' - """ - - if column_name is not None: - search_query += f"AND nome_coluna = '{column_name}'" - - search_query += "ORDER BY nome_coluna, chave" - - labels = { - "thread_id": config.get("configurable", {}).get("thread_id", "unknown"), - "user_id": config.get("configurable", {}).get("user_id", "unknown"), - "tool_name": inspect.currentframe().f_code.co_name, - } - - job_config = bq.QueryJobConfig(labels=labels) - query_job = client.query(search_query, job_config=job_config) - - rows = query_job.result() - results = [dict(row) for row in rows] - - tool_output = ToolOutput(status="success", results=results).model_dump(exclude_none=True) - return json.dumps(tool_output, ensure_ascii=False, default=str) - - -def get_tools() -> list[BaseTool]: - """Return all available tools for Base dos Dados database interaction. - - This function provides a complete set of tools for discovering, exploring, - and querying Brazilian public datasets through the Base dos Dados platform. - - Returns: - list[BaseTool]: A list of LangChain tool functions in suggested usage order: - - search_datasets: Find datasets using keywords - - get_dataset_details: Get comprehensive dataset information - - execute_bigquery_sql: Execute SQL queries against BigQuery tables - - decode_table_values: Decode coded values using dictionary tables - """ - return [ - search_datasets, - get_dataset_details, - execute_bigquery_sql, - decode_table_values, - ] diff --git a/backend/apps/chatbot/agent/types.py b/backend/apps/chatbot/agent/types.py deleted file mode 100644 index 9d857137d..000000000 --- a/backend/apps/chatbot/agent/types.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- -from typing import TypeVar - -StateT = TypeVar("StateT") diff --git a/backend/apps/chatbot/apps.py b/backend/apps/chatbot/apps.py index 046de65c1..9f10e68dd 100644 --- a/backend/apps/chatbot/apps.py +++ b/backend/apps/chatbot/apps.py @@ -6,6 +6,3 @@ class ChatbotConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "backend.apps.chatbot" verbose_name = "Chatbot" - - def ready(self): - import backend.apps.chatbot.checks # noqa: F401 diff --git a/backend/apps/chatbot/authentication.py b/backend/apps/chatbot/authentication.py deleted file mode 100644 index 9c677197e..000000000 --- a/backend/apps/chatbot/authentication.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -from backend.apps.account.models import Account - - -def authentication_rule(user: Account) -> bool: - if user is not None: - return user.has_chatbot_access - return False diff --git a/backend/apps/chatbot/checks.py b/backend/apps/chatbot/checks.py deleted file mode 100644 index b2642325a..000000000 --- a/backend/apps/chatbot/checks.py +++ /dev/null @@ -1,78 +0,0 @@ -# -*- coding: utf-8 -*- -import os - -from django.core.checks import Info, Warning, register - - -@register() -def check_gcloud_env_vars(app_configs, **kwargs): - """Validate chatbot environment variables.""" - checks = [] - - sa_file = os.getenv("CHATBOT_CREDENTIALS") - if not sa_file: - checks.append( - Warning( - "CHATBOT_CREDENTIALS not set - chatbot will not work properly", - hint="Set CHATBOT_CREDENTIALS=/path/to/service-account.json\n", - id="chatbot.W001", - ) - ) - elif not os.path.exists(sa_file): - checks.append( - Warning( - f"Service account file {sa_file} not found - chatbot will not work properly", - hint="Ensure the file exists at the specified path\n", - id="chatbot.W002", - ) - ) - - if not os.getenv("BIGQUERY_PROJECT_ID"): - checks.append( - Warning( - "BIGQUERY_PROJECT_ID not set - chatbot will not work properly", - hint="Set BIGQUERY_PROJECT_ID=your-gcp-project-id\n", - id="chatbot.W003", - ) - ) - - if not os.getenv("LANGSMITH_TRACING"): - checks.append( - Warning( - "LANGSMITH_TRACING not set - tracing will be disabled", - hint="Set LANGSMITH_TRACING=true\n", - id="chatbot.W004", - ) - ) - - if not os.getenv("LANGSMITH_API_KEY"): - checks.append( - Warning( - "LANGSMITH_API_KEY not set - tracing will be disabled", - hint="Set LANGSMITH_API_KEY=your-langsmith-api-key\n", - id="chatbot.W005", - ) - ) - - if not os.getenv("LANGSMITH_PROJECT"): - checks.append( - Warning( - "LANGSMITH_PROJECT not set - project 'default' will be used", - hint="Set LANGSMITH_PROJECT=your-project-name", - id="chatbot.W006", - ) - ) - - if not os.getenv("BASE_URL_BACKEND"): - checks.append( - Info( - "BASE_URL_BACKEND not set - defaulting to http://localhost:8000", - hint=( - "Default http://localhost:8000 works for same-server deployments. " - "Override only if you need an external backend, e.g., https://backend.basedosdados.org\n" - ), - id="chatbot.I001", - ) - ) - - return checks diff --git a/backend/apps/chatbot/feedback_sender.py b/backend/apps/chatbot/feedback_sender.py deleted file mode 100644 index 53715470b..000000000 --- a/backend/apps/chatbot/feedback_sender.py +++ /dev/null @@ -1,112 +0,0 @@ -# -*- coding: utf-8 -*- -from queue import Full, Queue -from threading import Thread - -import langsmith -from django.utils import timezone -from loguru import logger - -from backend.apps.chatbot.models import Feedback - - -class LangSmithFeedbackSender: - """A feedback sender that sends feedback to LangSmith using a background worker.""" - - def __init__(self, api_url: str | None = None, api_key: str | None = None): - self._langsmith_client = langsmith.Client(api_url=api_url, api_key=api_key) - - self._queue: Queue[tuple[Feedback, bool]] = Queue(maxsize=1000) - - self._thread = Thread(target=self._process_feedback, daemon=True) - self._thread.start() - - def _create_langsmith_feedback(self, feedback: Feedback) -> bool: - """Create feedback on LangSmith. - - Args: - feedback (Feedback): The feedback instance to create. - - Returns: - bool: True if successful, False otherwise. - """ - try: - _ = self._langsmith_client.create_feedback( - run_id=feedback.message_pair.id, - key="helpfulness", - feedback_id=feedback.id, - score=feedback.rating, - comment=feedback.comment, - ) - logger.info( - f"[CHATBOT] Successfully created feedback {feedback.id} " - f"for run {feedback.message_pair.id} on LangSmith" - ) - return True - except Exception: - logger.exception( - f"[CHATBOT] Failed to create feedback {feedback.id} " - f"for run {feedback.message_pair.id} on LangSmith" - ) - return False - - def _update_langsmith_feedback(self, feedback: Feedback) -> bool: - """Update existing feedback on LangSmith. - - Args: - feedback (Feedback): The feedback instance to update. - - Returns: - bool: True if successful, False otherwise. - """ - try: - self._langsmith_client.update_feedback( - feedback_id=feedback.id, score=feedback.rating, comment=feedback.comment - ) - logger.info( - f"[CHATBOT] Successfully updated feedback {feedback.id} " - f"for run {feedback.message_pair.id} on LangSmith" - ) - return True - except Exception: - logger.exception( - f"[CHATBOT] Failed to update feedback {feedback.id} " - f"for run {feedback.message_pair.id} on LangSmith" - ) - return False - - def _process_feedback(self): - """Background worker that continuously processes feedbacks from the queue. - Updates the feedback sync status in the local database after each operation. - """ - while True: - feedback, created = self._queue.get() - - if created: - success = self._create_langsmith_feedback(feedback) - else: - success = self._update_langsmith_feedback(feedback) - - feedback.sync_status = "success" if success else "failed" - feedback.synced_at = timezone.now() - feedback.save() - - self._queue.task_done() - - def send_feedback(self, feedback: Feedback, created: bool): - """Enqueue a feedback instance for creation or update on LangSmith. - - Args: - feedback (Feedback): The feedback instance to send. - created (bool): True if this is a new feedback, False if it's an update. - """ - try: - self._queue.put( - item=(feedback, created), - timeout=10, - ) - except Full: - operation = "create" if created else "update" - logger.warning( - f"[CHATBOT] LangSmith feedbacks queue is full - could not {operation} " - f"feedback {feedback.id} on LangSmith" - ) diff --git a/backend/apps/chatbot/mock/__init__.py b/backend/apps/chatbot/mock/__init__.py deleted file mode 100644 index 24896bee9..000000000 --- a/backend/apps/chatbot/mock/__init__.py +++ /dev/null @@ -1,270 +0,0 @@ -# -*- coding: utf-8 -*- -import json -import os -import time -from functools import wraps -from typing import Any, Iterator - -from backend.apps.chatbot.models import MessagePair, Thread -from backend.apps.chatbot.utils.stream import ( - EventData, - StreamEvent, - ToolCall, - ToolOutput, - _truncate_json, -) - -from .mock_data import ( - RAIS_DATASET_DETAILS, - RAIS_DATASET_SEARCH, - RAIS_DECODE_SEXO, - RAIS_DECODE_VINCULO_ATIVO, - RAIS_FINAL_RESPONSE, -) - - -def allow_agent_mock(func): - """Decorator to replace agent execution with mock streaming when MOCK_AGENT=true.""" - - @wraps(func) - def wrapper(message: str, config: dict, thread: Thread) -> Iterator[str]: - mock = os.getenv("MOCK_AGENT", "false").lower() == "true" - - if mock: - return _mock_agent(message, config, thread) - - return func(message, config, thread) - - return wrapper - - -def _mock_agent(message: str, config: dict, thread: Thread) -> Iterator[str]: - """Generate mock streaming events that simulate real agent behavior. - - Args: - message (str): User's input message. - config (ConfigDict): Configuration for agent execution. - thread (Thread): Conversation thread. - - Yields: - Iterator[str]: SSE-formatted event. - """ - events = [] - - _mock_thinking(2) - - # ========== STEP 1: Search for datasets ========== - search_call_event = StreamEvent( - type="tool_call", - data=EventData( - content=( - "Estou buscando dados sobre a proporção de mulheres no mercado de " - "trabalho formal. Acredito que o dataset da RAIS (Relação Anual de " - "Informações Sociais) seja a fonte mais adequada para essa informação, " - "pois ele contém dados detalhados sobre o emprego formal no Brasil.\n\n" - 'Vou começar pesquisando por "rais" para encontrar os datasets disponíveis.' - ), - tool_calls=[ - ToolCall( - id="call_search_datasets", - name="search_datasets", - args={"query": "rais"}, - ) - ], - ), - ) - - events.append(search_call_event.model_dump()) - yield search_call_event.to_sse() - - # Simulates calling the /search/ endpoint - _mock_tool_call(1) - - search_output_event = _mock_tool_output_event( - tool_call_id="call_search_datasets", - tool_name="search_datasets", - data=RAIS_DATASET_SEARCH, - ) - - events.append(search_output_event.model_dump()) - yield search_output_event.to_sse() - - _mock_thinking(2) - - # ========== STEP 2: Get dataset details ========== - dataset_details_call_event = StreamEvent( - type="tool_call", - data=EventData( - content=( - "Estou buscando dados sobre a proporção de mulheres no mercado de " - 'trabalho formal. A busca por "rais" retornou o dataset "Relação Anual ' - 'de Informações Sociais (RAIS)", que é o esperado. Agora, preciso obter ' - "os detalhes desse dataset para entender sua estrutura e quais tabelas " - "contêm as informações necessárias sobre gênero e mercado de trabalho formal." - ), - tool_calls=[ - ToolCall( - id="call_get_dataset_details", - name="get_dataset_details", - args={"dataset_id": "3e7c4d58-96ba-448e-b053-d385a829ef00"}, - ) - ], - ), - ) - - events.append(dataset_details_call_event.model_dump()) - yield dataset_details_call_event.to_sse() - - # Simulates GraphQL query + usage guide fetching - _mock_tool_call(2) - - dataset_details_output_event = _mock_tool_output_event( - tool_call_id="call_get_dataset_details", - tool_name="get_dataset_details", - data=RAIS_DATASET_DETAILS, - ) - - events.append(dataset_details_output_event.model_dump()) - yield dataset_details_output_event.to_sse() - - _mock_thinking(2) - - # ========== STEP 3: Decode "sexo" column ========== - decode_sexo_call_event = StreamEvent( - type="tool_call", - data=EventData( - content=( - "Estou analisando o dataset da RAIS para determinar a proporção de " - "mulheres no mercado de trabalho formal. A tabela `microdados_vinculos` " - "é a mais adequada para esta análise, pois contém a coluna `sexo`.\n\n" - "Antes de prosseguir com a consulta, vou decodificar os valores da coluna " - "`sexo` para entender como o gênero é representado nos dados." - ), - tool_calls=[ - ToolCall( - id="call_decode_table_values_1", - name="decode_table_values", - args={ - "table_gcp_id": "basedosdados.br_me_rais.microdados_estabelecimentos", - "column_name": "sexo", - }, - ) - ], - ), - ) - - events.append(decode_sexo_call_event.model_dump()) - yield decode_sexo_call_event.to_sse() - - # Simulates querying the dictionary table on BigQuery - _mock_tool_call(1) - - decoded_sexo_output_event = _mock_tool_output_event( - tool_call_id="call_decode_table_values_1", - tool_name="decode_table_values", - data=RAIS_DECODE_SEXO, - ) - - events.append(decoded_sexo_output_event.model_dump()) - yield decoded_sexo_output_event.to_sse() - - _mock_thinking(2) - - # ========== STEP 4: Decode "vinculo_ativo_3112" column ========== - decode_vinculo_ativo_call_event = StreamEvent( - type="tool_call", - data=EventData( - content=( - "Para calcular a proporção de mulheres no mercado de trabalho formal, " - "utilizarei a tabela `microdados_vinculos` do dataset da RAIS. Esta " - "tabela contém informações detalhadas sobre os vínculos empregatícios, " - "incluindo o sexo dos trabalhadores e se o vínculo estava ativo em 31/12.\n\n" - "Antes de construir a consulta final, vou decodificar os valores da coluna " - "`vinculo_ativo_3112` para garantir que estou filtrando corretamente os " - "vínculos ativos." - ), - tool_calls=[ - ToolCall( - id="call_decode_table_values_2", - name="decode_table_values", - args={ - "table_gcp_id": "basedosdados.br_me_rais.microdados_estabelecimentos", - "column_name": "vinculo_ativo_3112", - }, - ) - ], - ), - ) - - events.append(decode_vinculo_ativo_call_event.model_dump()) - yield decode_vinculo_ativo_call_event.to_sse() - - # Simulates querying the dictionary table on BigQuery - _mock_tool_call(1) - - decoded_vinculo_ativo_output_event = _mock_tool_output_event( - tool_call_id="call_decode_table_values_2", - tool_name="decode_table_values", - data=RAIS_DECODE_VINCULO_ATIVO, - ) - - events.append(decoded_vinculo_ativo_output_event.model_dump()) - yield decoded_vinculo_ativo_output_event.to_sse() - - # The agent usually takes longer to generate the final response - _mock_thinking(5) - - # ========== STEP 5: Send final response ========== - final_answer_event = StreamEvent( - type="final_answer", data=EventData(content=RAIS_FINAL_RESPONSE) - ) - - events.append(final_answer_event.model_dump()) - yield final_answer_event.to_sse() - - # ========== STEP 6: Save message and complete ========== - message_pair = MessagePair.objects.create( - id=config["run_id"], - thread=thread, - model_uri="SIMULATED_MODEL", - user_message=message, - assistant_message=RAIS_FINAL_RESPONSE, - error_message=None, - events=events, - ) - - complete_event = StreamEvent(type="complete", data=EventData(run_id=message_pair.id)) - - yield complete_event.to_sse() - - -# =============================== -# Helper Methods -# =============================== -def _mock_thinking(t: float): - time.sleep(t) - - -def _mock_tool_call(t: float): - time.sleep(t) - - -def _mock_tool_output_event(tool_call_id: str, tool_name: str, data: dict[str, Any]) -> StreamEvent: - """Create a mock tool output event from data. - - Args: - tool_call_id: Unique identifier for the tool call. - tool_name: Name of the tool being mocked. - data: Data dictionary to serialize. - - Returns: - StreamEvent containing the tool output. - """ - tool_output = ToolOutput( - status="success", - tool_call_id=tool_call_id, - tool_name=tool_name, - output=_truncate_json(json.dumps(data, ensure_ascii=False, indent=2)), - ) - - return StreamEvent(type="tool_output", data=EventData(tool_outputs=[tool_output])) diff --git a/backend/apps/chatbot/mock/mock_data.py b/backend/apps/chatbot/mock/mock_data.py deleted file mode 100644 index d1551dd22..000000000 --- a/backend/apps/chatbot/mock/mock_data.py +++ /dev/null @@ -1,528 +0,0 @@ -# -*- coding: utf-8 -*- -RAIS_DATASET_SEARCH = { - "status": "success", - "results": [ - { - "id": "3e7c4d58-96ba-448e-b053-d385a829ef00", - "name": "Relação Anual de Informações Sociais (RAIS)", - "slug": "rais", - "description": "A Relação Anual de Informações Sociais (RAIS) é um relatório de informações socioeconômicas solicitado pela Secretaria de Trabalho do Ministério da Economia brasileiro às pessoas jurídicas e outros empregadores anualmente. Foi instituída pelo Decreto nº 76.900, de 23 de dezembro de 1975.", # noqa: E501 - "tags": ["emprego", "trabalho"], - "themes": ["Economia"], - "organizations": ["Ministério da Economia (ME)"], - }, - { - "id": "562b56a3-0b01-4735-a049-eeac5681f056", - "name": "Cadastro Geral de Empregados e Desempregados (CAGED)", - "slug": "caged", - "description": "O Cadastro Geral de Empregados e Desempregados – CAGED, instituído pela Lei nº 4.923, em 23 de dezembro de 1965, constitui fonte de informação de âmbito nacional e de periodicidade mensal. Foi criado como instrumento de acompanhamento e de fiscalização do processo de admissão e de dispensa de trabalhadores regidos pela CLT, com o objetivo de assistir os desempregados e de apoiar medidas contra o desemprego.\r\n\r\nO CAGED é um Registro Administrativo, e, inicialmente, objetivou gerir e controlar a concessão do auxílio-desemprego. A partir de 1986, passou a ser utilizado como suporte ao pagamento do seguro desemprego e, mais recentemente, tornou-se, também, um relevante instrumento à reciclagem profissional e à recolocação do trabalhador no mercado de trabalho e, ainda, um importante subsídio para a fiscalização.\r\n\r\nDevido à crescente demanda por dados conjunturais do mercado de trabalho e à necessidade deste Ministério em contar com estatísticas mais completas, mais consistentes e mais ágeis, foram implementadas expressivas alterações ao sistema – Lei nº 4.923/65. Como decorrência dos substanciais avanços, pôde-se construir, a partir de 1983, o índice mensal de emprego, a taxa de rotatividade e a flutuação da mão-de-obra (admitidos / desligados).\r\n\r\nOs aperfeiçoamentos ocorridos no sistema CAGED e também na metodologia de tratamento dos dados tornaram esse registro administrativo uma das principais fontes de informações estatísticas sobre o mercado de trabalho conjuntural. O CAGED apresenta desagregações idênticas às da RAIS, em termos geográficos, setoriais e ocupacionais, possibilitando a realização de estudos que indicam as tendências mais atuais. No espectro conjuntural, é a única fonte de informação com tal nível de desagregação, sendo, portanto, imprescindível para o balizamento das intervenções dos formuladores de políticas na esfera do mercado de trabalho, aumentando a eficácia e eficiência das políticas de emprego que possibilitam o aumento do número e da qualidade de postos de trabalho e, por conseguinte, a redução da desigualdade social.\r\n\r\nA qualidade das informações do CAGED vem apresentando significativa melhora. Concorreu para esse fato a implantação da Portaria nº 561/2001 que determinou a extinção da declaração do CAGED em formulário padrão a partir da competência de novembro de 2001. Esta medida teve um impacto positivo na qualidade, uma vez que as informações declaradas, em meios eletrônicos, passam por um processo de críticas. Ademais, a implantação da recepção do CAGED, via Internet, possibilitou, também, um ganho na tempestividade.", # noqa: E501 - "tags": ["emprego", "empresa", "firma", "trabalho"], - "themes": ["Economia"], - "organizations": ["Ministério da Economia (ME)"], - }, - ], -} - -RAIS_DATASET_DETAILS = { - "status": "success", - "results": { - "id": "DatasetNode:3e7c4d58-96ba-448e-b053-d385a829ef00", - "name": "Relação Anual de Informações Sociais (RAIS)", - "slug": "rais", - "description": "A Relação Anual de Informações Sociais (RAIS) é um relatório de informações socioeconômicas solicitado pela Secretaria de Trabalho do Ministério da Economia brasileiro às pessoas jurídicas e outros empregadores anualmente. Foi instituída pelo Decreto nº 76.900, de 23 de dezembro de 1975.", # noqa: E501 - "tags": ["emprego", "trabalho"], - "themes": ["Economia"], - "organizations": ["Ministério da Economia (ME)"], - "tables": [ - { - "id": "TableNode:c3a5121e-f00d-41ff-b46f-bd26be8d4af3", - "gcp_id": "basedosdados.br_me_rais.dicionario", - "name": "Dicionário", - "slug": "dicionario", - "description": "Dicionário para tradução dos códigos das tabelas do do conjunto Relação Anual de Informações Sociais (RAIS). Para códigos definidos por outras instituições, como id_municipio ou cnaes, buscar por diretórios", # noqa: E501 - "temporal_coverage": {"start": None, "end": None}, - "columns": [ - {"name": "chave", "type": "STRING", "description": "Chave"}, - { - "name": "cobertura_temporal", - "type": "STRING", - "description": "Cobertura Temporal", - }, - {"name": "id_tabela", "type": "STRING", "description": "ID Tabela"}, - {"name": "nome_coluna", "type": "STRING", "description": "Nome da coluna"}, - {"name": "valor", "type": "STRING", "description": "Valor"}, - ], - }, - { - "id": "TableNode:86b69f96-0bfe-45da-833b-6edc9a0af213", - "gcp_id": "basedosdados.br_me_rais.microdados_estabelecimentos", - "name": "Microdados Estabelecimentos", - "slug": "microdados_estabelecimentos", - "description": "Microdados de estabelecimentos da RAIS.", - "temporal_coverage": {"start": "1985", "end": "2024"}, - "columns": [ - {"name": "ano", "type": "INT64", "description": "Ano"}, - { - "name": "bairros_fortaleza", - "type": "STRING", - "description": "Bairros do município de Fortaleza", - }, - { - "name": "bairros_rj", - "type": "STRING", - "description": "Bairros do município do Rio de Janeiro", - }, - { - "name": "bairros_sp", - "type": "STRING", - "description": "Bairros do Municipio de São Paulo", - }, - { - "name": "cep", - "type": "STRING", - "description": "Código de Endereçamento Postal", - }, - { - "name": "cnae_1", - "type": "STRING", - "description": "Código Nacional de Atividades Econômicas 1.0", - }, - { - "name": "cnae_2", - "type": "STRING", - "description": "Código Nacional de Atividades Econômicas 2.0", - }, - { - "name": "cnae_2_subclasse", - "type": "STRING", - "description": "Subclasse do Código Nacional de Atividades Econômicas 2.0", - }, - { - "name": "distritos_sp", - "type": "STRING", - "description": "Distritos do município de São Paulo", - }, - { - "name": "id_municipio", - "type": "STRING", - "description": "ID Município - IBGE 7 Dígitos", - }, - { - "name": "indicador_atividade_ano", - "type": "INT64", - "description": "Indicador de estabelecimento/entidade que exerceu atividade durante o ano de referência.", # noqa: E501 - }, - { - "name": "indicador_cei_vinculado", - "type": "INT64", - "description": "Indicador de CEI vinculado.", - }, - { - "name": "indicador_pat", - "type": "INT64", - "description": "Indicador de estabelecimento pertencente ao PAT.", - }, - { - "name": "indicador_rais_negativa", - "type": "INT64", - "description": "Indicador de Rais Negativa.", - }, - { - "name": "indicador_simples", - "type": "INT64", - "description": "Indicador de optante pelo SIMPLES.", - }, - { - "name": "natureza_estabelecimento", - "type": "STRING", - "description": "Natureza do Estabelecimento", - }, - { - "name": "natureza_juridica", - "type": "STRING", - "description": "Natureza jurídica (CONCLA/2002)", - }, - { - "name": "quantidade_vinculos_ativos", - "type": "INT64", - "description": "Estoque de vínculos ativos em 31/12.", - }, - { - "name": "quantidade_vinculos_clt", - "type": "INT64", - "description": "Estoque de vínculos, sob o regime CLT e Outros, ativos em 31/12", # noqa: E501 - }, - { - "name": "quantidade_vinculos_estatutarios", - "type": "INT64", - "description": "Estoque de vínculos, sob o regime estatutário, ativos em 31/12", # noqa: E501 - }, - { - "name": "regioes_administrativas_df", - "type": "STRING", - "description": "Regiões Administrativas do Distrito Federal", - }, - { - "name": "sigla_uf", - "type": "STRING", - "description": "Sigla da Unidade da Federação", - }, - { - "name": "subatividade_ibge", - "type": "STRING", - "description": "Subatividade IBGE", - }, - {"name": "subsetor_ibge", "type": "STRING", "description": "Subsetor IBGE"}, - { - "name": "tamanho_estabelecimento", - "type": "STRING", - "description": "Tamanho - empregados ativos em 31/12.", - }, - { - "name": "tipo_estabelecimento", - "type": "STRING", - "description": "Tipo do Estabelecimento", - }, - ], - }, - { - "id": "TableNode:dabe5ea8-3bb5-4a3e-9d5a-3c7003cd4a60", - "gcp_id": "basedosdados.br_me_rais.microdados_vinculos", - "name": "Microdados Vínculos", - "slug": "microdados_vinculos", - "description": "Microdados públicos dos vínculos de emprego na RAIS. Base desidentificada, isto é, que não inclui identificadores únicos de linha. Cada linha representa um vínculo - por isso indicamos este como nível de observação mesmo que não conste como coluna.", # noqa: E501 - "temporal_coverage": {"start": "1985", "end": "2024"}, - "columns": [ - {"name": "ano", "type": "INT64", "description": "Ano"}, - { - "name": "ano_chegada_brasil", - "type": "INT64", - "description": "Ano de Chegada no Brasil", - }, - { - "name": "bairros_fortaleza", - "type": "STRING", - "description": "Bairros em Fortaleza", - }, - { - "name": "bairros_rj", - "type": "STRING", - "description": "Bairros no Rio de Janeiro", - }, - {"name": "bairros_sp", "type": "STRING", "description": "Bairros em São Paulo"}, - { - "name": "causa_desligamento_1", - "type": "STRING", - "description": "Causa 1 do Desligamento", - }, - { - "name": "causa_desligamento_2", - "type": "STRING", - "description": "Causa 2 do Desligamento", - }, - { - "name": "causa_desligamento_3", - "type": "STRING", - "description": "Causa 3 do Desligamento", - }, - { - "name": "cbo_1994", - "type": "STRING", - "description": "Classificação Brasileira de Ocupações (CBO) 1994", - }, - { - "name": "cbo_2002", - "type": "STRING", - "description": "Classificação Brasileira de Ocupações (CBO) 2002", - }, - { - "name": "cnae_1", - "type": "STRING", - "description": "Classificação Nacional de Atividades Econômicas (CNAE) 1.0", - }, - { - "name": "cnae_2", - "type": "STRING", - "description": "Classificação Nacional de Atividades Econômicas (CNAE) 2.0", - }, - { - "name": "cnae_2_subclasse", - "type": "STRING", - "description": "Classificação Nacional de Atividades Econômicas (CNAE) 2.0 Subclasse", # noqa: E501 - }, - { - "name": "distritos_sp", - "type": "STRING", - "description": "Distritos em São Paulo", - }, - {"name": "faixa_etaria", "type": "STRING", "description": "Faixa Etária"}, - { - "name": "faixa_horas_contratadas", - "type": "STRING", - "description": "Faixa Horas Contratadas", - }, - { - "name": "faixa_remuneracao_dezembro_sm", - "type": "STRING", - "description": "Faixa Remuneração em Dezembro (Salários Mínimos)", - }, - { - "name": "faixa_remuneracao_media_sm", - "type": "STRING", - "description": "Faixa Remuneração Média (Salários Mínimos)", - }, - { - "name": "faixa_tempo_emprego", - "type": "STRING", - "description": "Faixa Tempo Emprego", - }, - { - "name": "grau_instrucao_1985_2005", - "type": "STRING", - "description": "Grau de Instrução 1985-2005", - }, - { - "name": "grau_instrucao_apos_2005", - "type": "STRING", - "description": "Grau de Instrução Após 2005", - }, - {"name": "idade", "type": "INT64", "description": "Idade"}, - { - "name": "id_municipio", - "type": "STRING", - "description": "ID Município - IBGE 7 Dígitos", - }, - { - "name": "id_municipio_trabalho", - "type": "STRING", - "description": "ID Município de Trabalho - IBGE 7 Dígitos", - }, - { - "name": "indicador_cei_vinculado", - "type": "STRING", - "description": "Indicador CEI Vinculado", - }, - { - "name": "indicador_portador_deficiencia", - "type": "STRING", - "description": "Indicador de Portador de Deficiência", - }, - { - "name": "indicador_simples", - "type": "STRING", - "description": "Indicador do Simples", - }, - { - "name": "indicador_trabalho_intermitente", - "type": "STRING", - "description": "Indicador Trabalho Intermitente", - }, - { - "name": "indicador_trabalho_parcial", - "type": "STRING", - "description": "Indicador Trabalho Parcial", - }, - {"name": "mes_admissao", "type": "INT64", "description": "Mês de Admissão"}, - { - "name": "mes_desligamento", - "type": "INT64", - "description": "Mês de Desligamento", - }, - { - "name": "motivo_desligamento", - "type": "STRING", - "description": "Motivo do Desligamento", - }, - {"name": "nacionalidade", "type": "STRING", "description": "Nacionalidade"}, - { - "name": "natureza_juridica", - "type": "STRING", - "description": "Natureza Jurídica do Estabelecimento", - }, - { - "name": "quantidade_dias_afastamento", - "type": "INT64", - "description": "Quantidade de Dias sob Afastamento", - }, - { - "name": "quantidade_horas_contratadas", - "type": "INT64", - "description": "Quantidade de Horas Contratadas", - }, - {"name": "raca_cor", "type": "STRING", "description": "Raça ou Cor"}, - { - "name": "regioes_administrativas_df", - "type": "STRING", - "description": "Regiões Administrativas no Distrito Federal", - }, - {"name": "sexo", "type": "STRING", "description": "Sexo"}, - { - "name": "sigla_uf", - "type": "STRING", - "description": "Sigla da Unidade da Federação", - }, - { - "name": "subatividade_ibge", - "type": "STRING", - "description": "Subatividade - IBGE", - }, - {"name": "subsetor_ibge", "type": "STRING", "description": "Subsetor - IBGE"}, - { - "name": "tamanho_estabelecimento", - "type": "STRING", - "description": "Tamanho do Estabelecimento", - }, - {"name": "tempo_emprego", "type": "FLOAT64", "description": "Tempo Emprego"}, - {"name": "tipo_admissao", "type": "STRING", "description": "Tipo da Admissão"}, - { - "name": "tipo_deficiencia", - "type": "STRING", - "description": "Tipo da Deficiência", - }, - { - "name": "tipo_estabelecimento", - "type": "STRING", - "description": "Tipo do Estabelecimento", - }, - {"name": "tipo_salario", "type": "STRING", "description": "Tipo do Salário"}, - {"name": "tipo_vinculo", "type": "STRING", "description": "Tipo do Vínculo"}, - { - "name": "valor_remuneracao_abril", - "type": "FLOAT64", - "description": "Valor da Remuneração em Abril", - }, - { - "name": "valor_remuneracao_agosto", - "type": "FLOAT64", - "description": "Valor da Remuneração em Agosto", - }, - { - "name": "valor_remuneracao_dezembro", - "type": "FLOAT64", - "description": "Valor da Remuneração em Dezembro", - }, - { - "name": "valor_remuneracao_dezembro_sm", - "type": "FLOAT64", - "description": "Valor da Remuneração em Dezembro (Salários Mínimos)", - }, - { - "name": "valor_remuneracao_fevereiro", - "type": "FLOAT64", - "description": "Valor da Remuneração em Fevereiro", - }, - { - "name": "valor_remuneracao_janeiro", - "type": "FLOAT64", - "description": "Valor da Remuneração em Janeiro", - }, - { - "name": "valor_remuneracao_julho", - "type": "FLOAT64", - "description": "Valor da Remuneração em Julho", - }, - { - "name": "valor_remuneracao_junho", - "type": "FLOAT64", - "description": "Valor da Remuneração em Junho", - }, - { - "name": "valor_remuneracao_maio", - "type": "FLOAT64", - "description": "Valor da Remuneração em Maio", - }, - { - "name": "valor_remuneracao_marco", - "type": "FLOAT64", - "description": "Valor da Remuneração em Março", - }, - { - "name": "valor_remuneracao_media", - "type": "FLOAT64", - "description": "Valor da Remuneração Média", - }, - { - "name": "valor_remuneracao_media_sm", - "type": "FLOAT64", - "description": "Valor da Remuneração Média (Salários Mínimos)", - }, - { - "name": "valor_remuneracao_novembro", - "type": "FLOAT64", - "description": "Valor da Remuneração em Novembro", - }, - { - "name": "valor_remuneracao_outubro", - "type": "FLOAT64", - "description": "Valor da Remuneração em Outubro", - }, - { - "name": "valor_remuneracao_setembro", - "type": "FLOAT64", - "description": "Valor da Remuneração em Setembro", - }, - { - "name": "valor_salario_contratual", - "type": "FLOAT64", - "description": "Valor Contratual do Salário", - }, - { - "name": "vinculo_ativo_3112", - "type": "STRING", - "description": "Vínculo Ativo no dia 31/12", - }, - ], - }, - ], - "usage_guide": '---\ntitle: Guia de uso da RAIS\ndescription: >-\n Guia de uso da Relação Anual de Informações Sociais (RAIS). Este material contém informações sobre as variáveis mais importantes, perguntas frequentes e exemplos de uso do conjunto da RAIS \ndate:\n created: "2024-11-28T18:18:06.419Z"\nthumbnail: \ncategories: [guia-de-uso]\nauthors:\n - name: Laura Amaral\n role: Texto\n---\n\n# Introdução\n\n> O guia contém informações detalhadas sobre os dados. Para dúvidas sobre acesso ou uso da plataforma, consulte nossa [página de Perguntas Frequentes](/faq).\n\nEste conjunto de dados possui duas tabelas de microdados: \n- **Microdados Estabelecimentos:** Cada linha representa um estabelecimento em um ano específico. As colunas mostram detalhes sobre a empresa e seus empregados.\n- **Microdados Vínculos:** Cada linha representa um vínculo de trabalho em um ano específico. As colunas mostram informações sobre o vínculo, o empregado e a empresa contratante.\n\n# Considerações para análises\n## Vínculos e filtragem de dados\nA tabela de vínculos mostra todos os vínculos registrados por uma empresa durante o ano. Se um empregado for demitido e outro contratado no mesmo ano, ambos terão uma registro de vínculo para a mesma posição. Para contar os empregados ativos em um setor ou região, use a coluna `vinculo_ativo_3112`.\n\n## Informações de endereço\nA RAIS não possui informações sobre o endereço dos empregados. A coluna `id_municipio` se refere ao município da empresa, e a coluna `id_municipio_trabalho` se refere ao município onde o trabalhador presta serviços, caso seja diferente.\n\n## Dados parciais e dados completos\nA RAIS é divulgada duas vezes ao ano. Entre a divulgação parcial (setembro) e a completa (início do ano seguinte), o último ano da série sempre aparece com menos registros. Por exemplo, em novembro de 2025, o ano de 2024 mostra cerca de 46 milhões de vínculos, enquanto 2022 e 2023 têm mais de 50 milhões. Isso não significa que o número de vínculos caiu — só quer dizer que os dados de 2024 ainda não foram totalmente liberados.\n\n# Limitações\nOs dados são limitados a trabalhadores com vínculo formal e não incluem trabalhadores informais ou autônomos. Os dados públicos são anonimizados.\n\n# Inconsistências\n## Colunas `quantidade_vinculos_ativos` e `tamanho_estabelecimento`\nHá discrepâncias entre as colunas `quantidade_vinculos_ativos` e `tamanho_estabelecimento`. A primeira mostra o total de vínculos, enquanto a segunda classifica o estabelecimento por número de vínculos. Em alguns casos, a quantidade de vínculos não corresponde à categoria do tamanho do estabelecimento.\n\n## Vínculos de trabalho na RAIS e no CAGED\nA RAIS registra vínculos de trabalho anualmente e o CAGED registra movimentações durante o ano. Teoricamente, somando ou subtraindo as movimentações do CAGED ao total de vínculos da RAIS, seria possível calcular o total do ano seguinte, mas isso não acontece. Como os sistemas operam de forma independente, as divergências podem ser causadas por erros acumulados em cada um. \n\n## Coluna id_municipio_trabalho\nA coluna `id_municipio_trabalho` está preenchida apenas entre 2005-2011 e 2017-2021. Não se sabe o motivo. \n\n## Dados desatualizados\nÀs vezes, os dados da RAIS são atualizados fora do calendário esperado e nossa equipe nem sempre fica sabendo. Se você está confiante de que está fazendo as queries corretas, entre em contato conosco enviando a query e a diferença com o site oficial, para que possamos avaliar a situação e, se necessário, corrigir. \n\n# Observações ao longo tempo\nA cada ano, o conjunto de dados é atualizado, fazendo com que um estabelecimento ou vínculo apareça em todos os anos em que esteve ativo. Como os dados são anonimizados, não é possível acompanhar a evolução de vínculos ou empresas ao longo do tempo, mas é possível analisar o número de empregados com carteira de trabalho em diferentes setores ou locais.\n\n# Linhas duplicadas\nNão foram encontradas linhas duplicadas neste conjunto de dados. No entanto, a tabela Microdados Vínculos inclui todos os vínculos de uma empresa, então, se um empregado foi demitido e outro contratado no mesmo ano, terão duas linhas para a mesma posição.\n\n# Cruzamentos\nOs dados são anonimizadas, não contendo CNPJs nem CPFs. Isso limita os cruzamentos com outros conjuntos, mas é possível usar colunas como `cnae` e `cep` para tal.\n\n# Download dos dados\nOs microdados somam mais de 350 GB. Para evitar sobrecarregar seu computador, recomendamos usar queries no BigQuery para processar os dados em nuvem antes de baixá-los. Filtre pelas colunas de partição (como `ano` e `sigla_uf`) e selecione apenas as colunas relevantes.\n\n# Instrumento de coleta\nO instrumento de coleta atual é um formulário que deve ser preenchido pelos empregadores sobre seus empregados.\n\n# Mudanças na coleta\nAlgumas colunas foram adicionadas ou retiradas ao longo do tempo. A partir do ano de 2022 as empresas do grupo 3 do eSocial ficaram desobrigadas a declarar a RAIS pelo seu programa usual. Assim não é recomendável a comparação dos resultados desse ano com os resultados do anos anteriores.\n\n# Atualizações\nOs dados têm atualização parcial e completa. A atualização parcial ocorre em setembro do ano de coleta e a completa ocorre até o início do ano seguinte ao ano de coleta. Isso significa que os dados referentes a 2023, que foram coletados em 2024, ficaram parcialmente disponíveis em setembro de 2024 e a versão completa foi disponibilizada até o início de 2025. Às vezes, a atualização pode ocorrer fora do calendário previsto. Se perceber que os dados estão desatualizados, entre em contato com nossa equipe.\n\n# Dados identificados\nOs dados são anonimizados, não contendo CNPJs nem CPFs. Para obter dados identificados da RAIS, é necessário solicitar ao MTE. O processo pode ser demorado e não há garantia de aprovação.\n\n# Tratamentos feitos pela BD\nNeste guia, os tratamentos são descritos em uma linguagem mais acessível. De maneira complementar, os [códigos de tratamento](https://github.com/basedosdados/queries-basedosdados/tree/main/models/br_me_rais/code) e as [modificações feitas no BigQuery](https://github.com/basedosdados/queries-basedosdados/tree/main/models/br_me_rais) estão disponíveis no repositório do GitHub para consulta. \nOs tratamentos realizados foram: \n* Adequação das colunas que identificam municípios ao formato ID Município IBGE (7 dígitos);\n* Adequação das colunas que identificam Unidades Federativas ao padrão de sigla UF;\n* Substituição de códigos inválidos (como “9999” ou “000”) por valores nulos nas colunas de `bairros`, `cbo`, `cnae` e `ano`;\n* Padronização dos códigos na coluna `tipo_estabelecimento` para garantir consistência entre diferentes anos.\n\n# Materiais de apoio\n* [Manual de orientação para os empregadores sobre como preencher os campos do formulário](http://www.rais.gov.br/sitio/rais_ftp/ManualRAIS2023.pdf)\n* [Informações detalhadas sobre a RAIS no site do MTE](http://www.rais.gov.br/sitio/sobre.jsf)\n* [Dashboard do MTE com números consolidados da RAIS completa](https://app.powerbi.com/view?r=eyJrIjoiZmJmMDVhODctMTEwOS00YTVhLWJhNzItOWE3NmVlMWEwMTUxIiwidCI6IjNlYzkyOTY5LTVhNTEtNGYxOC04YWM5LWVmOThmYmFmYTk3OCJ9)\n* [Dashboard do MTE com números consolidados da RAIS parcial](https://app.powerbi.com/view?r=eyJrIjoiNjk3M2IwZDYtOGQzMS00YmE1LWE3M2MtZWRjODA4NTk3YTQ2IiwidCI6IjNlYzkyOTY5LTVhNTEtNGYxOC04YWM5LWVmOThmYmFmYTk3OCJ9)', # noqa: E501 - }, -} - -RAIS_DECODE_SEXO = { - "status": "success", - "results": [ - {"nome_coluna": "sexo", "chave": "-1", "valor": "Ignorado"}, - {"nome_coluna": "sexo", "chave": "1", "valor": "Masculino"}, - {"nome_coluna": "sexo", "chave": "2", "valor": "Feminino"}, - { - "nome_coluna": "sexo", - "chave": "9", - "valor": "Código não encontrado nos dicionários oficiais.", - }, - ], -} - -RAIS_DECODE_VINCULO_ATIVO = { - "status": "success", - "results": [ - {"nome_coluna": "vinculo_ativo_3112", "chave": "0", "valor": "Não"}, - {"nome_coluna": "vinculo_ativo_3112", "chave": "1", "valor": "Sim"}, - ], -} - -RAIS_FINAL_RESPONSE = """Esta consulta SQL calcula a proporção de mulheres no mercado de trabalho formal brasileiro para o ano mais recente disponível, 2024, utilizando os dados da Relação Anual de Informações Sociais (RAIS). Ela considera apenas os vínculos de trabalho ativos em 31 de dezembro de 2024. - -```sql -SELECT - ano, - COUNTIF(sexo = '2') AS total_mulheres_formal, -- Conta o número de mulheres no mercado formal - COUNT(*) AS total_vinculos_formal, -- Conta o número total de vínculos formais - SAFE_DIVIDE(COUNTIF(sexo = '2'), COUNT(*)) AS proporcao_mulheres_formal -- Calcula a proporção de mulheres -FROM - basedosdados.br_me_rais.microdados_vinculos AS t -WHERE - ano = 2024 -- Filtra para o ano mais recente disponível - AND vinculo_ativo_3112 = '1' -- Considera apenas vínculos ativos em 31/12 -GROUP BY - ano -ORDER BY - ano DESC -``` - -A consulta seleciona o ano, conta o total de vínculos formais ativos, o total de vínculos formais ativos de mulheres e, em seguida, calcula a proporção de mulheres no mercado de trabalho formal. Os dados são filtrados para o ano de 2024 e incluem apenas os vínculos que estavam ativos em 31 de dezembro daquele ano, garantindo uma representação precisa do mercado de trabalho formal. - -Você pode adaptar esta consulta para: -* Analisar outros anos, alterando o valor no filtro `WHERE ano = 2024`. -* Incluir outras dimensões, como `sigla_uf` ou `id_municipio`, adicionando-as ao `SELECT` e ao `GROUP BY` para obter a proporção por estado ou município. -* Explorar a proporção de mulheres em setores específicos, adicionando filtros ou agrupamentos por colunas como `cnae_2_subclasse`. -""" # noqa: E501 diff --git a/backend/apps/chatbot/serializers.py b/backend/apps/chatbot/serializers.py deleted file mode 100644 index a2c4973c8..000000000 --- a/backend/apps/chatbot/serializers.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -import uuid - -from rest_framework import serializers - -from .models import Feedback, MessagePair, Thread - - -class FeedbackCreateSerializer(serializers.ModelSerializer): - class Meta: - model = Feedback - fields = ["rating", "comment"] - - -class FeedbackSerializer(serializers.ModelSerializer): - class Meta: - model = Feedback - fields = "__all__" - - -class MessagePairSerializer(serializers.ModelSerializer): - class Meta: - model = MessagePair - fields = "__all__" - - -class ThreadCreateSerializer(serializers.ModelSerializer): - class Meta: - model = Thread - fields = ["title"] - - -class ThreadSerializer(serializers.ModelSerializer): - class Meta: - model = Thread - fields = "__all__" - - -class UserMessageSerializer(serializers.Serializer): - id = serializers.CharField(default=str(uuid.uuid4)) - content = serializers.CharField() diff --git a/backend/apps/chatbot/tests/__init__.py b/backend/apps/chatbot/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/apps/chatbot/tests/test_endpoints.py b/backend/apps/chatbot/tests/test_endpoints.py deleted file mode 100644 index 905deda3f..000000000 --- a/backend/apps/chatbot/tests/test_endpoints.py +++ /dev/null @@ -1,424 +0,0 @@ -# -*- coding: utf-8 -*- -import time -import uuid - -import pytest -from django.utils.dateparse import parse_datetime -from langchain_core.messages import AIMessage -from rest_framework.test import APIClient - -from backend.apps.account.models import Account -from backend.apps.chatbot import views -from backend.apps.chatbot.models import Feedback, MessagePair, Thread - - -class MockLangSmithFeedbackSender: - def __init__(self, *args, **kwargs): - ... - - def send_feedback(self, *args, **kwargs): - ... - - -class MockReActAgent: - def __init__(self, *args, **kwargs): - ... - - def stream(self, *args, **kwargs): - yield "updates", {"agent": {"messages": [AIMessage("mock response")]}} - - def clear_thread(self, *args, **kwargs): - ... - - -def mock_get_sql_agent(): - yield MockReActAgent() - - -@pytest.fixture -def mock_email() -> str: - return "mockemail@mockdomain.com" - - -@pytest.fixture -def mock_password() -> str: - return "mockpassword" - - -@pytest.fixture -def client() -> APIClient: - return APIClient() - - -@pytest.fixture -def auth_user(mock_email: str, mock_password: str) -> Account: - return Account.objects.create( - email=mock_email, - password=mock_password, - is_active=True, - has_chatbot_access=True, - ) - - -@pytest.fixture -def access_token(client: APIClient, mock_email: str, mock_password: str, auth_user: Account) -> str: - response = client.post( - path="/chatbot/token/", data={"email": mock_email, "password": mock_password} - ) - assert response.status_code == 200 - - return response.data["access"] - - -@pytest.fixture -def auth_client(access_token) -> APIClient: - client = APIClient() - client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") - return client - - -@pytest.mark.django_db -def test_token_view_authorized(client: APIClient, mock_email: str, mock_password: str): - _ = Account.objects.create( - email=mock_email, - password=mock_password, - is_active=True, - has_chatbot_access=True, - ) - - response = client.post( - path="/chatbot/token/", data={"email": mock_email, "password": mock_password} - ) - - assert response.status_code == 200 - - -@pytest.mark.django_db -def test_token_view_unauthorized(client: APIClient, mock_email: str, mock_password: str): - _ = Account.objects.create( - email=mock_email, - password=mock_password, - is_active=True, - # has_chatbot_access = False - has_chatbot_access is False by default - ) - - response = client.post( - path="/chatbot/token/", data={"email": mock_email, "password": mock_password} - ) - - assert response.status_code == 401 - - -@pytest.mark.django_db -def test_token_view_user_not_registered(client: APIClient, mock_email: str, mock_password: str): - response = client.post( - path="/chatbot/token/", data={"email": mock_email, "password": mock_password} - ) - - assert response.status_code == 401 - - -@pytest.mark.django_db -def test_thread_list_view_get(auth_client: APIClient): - response = auth_client.get("/chatbot/threads/") - assert response.status_code == 200 - assert isinstance(response.json(), list) - - -@pytest.mark.django_db -def test_thread_list_view_get_order_asc(auth_client: APIClient, auth_user: Account): - for _ in range(2): - _ = Thread.objects.create(account=auth_user) - time.sleep(1) - - response = auth_client.get("/chatbot/threads/?order_by=created_at") - assert response.status_code == 200 - - threads = response.json() - - assert isinstance(threads, list) - - thread_1_created_at = parse_datetime(threads[0]["created_at"]) - thread_2_created_at = parse_datetime(threads[1]["created_at"]) - - assert thread_1_created_at < thread_2_created_at - - -@pytest.mark.django_db -def test_thread_list_view_get_order_desc(auth_client: APIClient, auth_user: Account): - for _ in range(2): - _ = Thread.objects.create(account=auth_user) - time.sleep(1) - - response = auth_client.get("/chatbot/threads/?order_by=-created_at") - assert response.status_code == 200 - - threads = response.json() - - assert isinstance(threads, list) - - thread_1_created_at = parse_datetime(threads[0]["created_at"]) - thread_2_created_at = parse_datetime(threads[1]["created_at"]) - - assert thread_1_created_at > thread_2_created_at - - -@pytest.mark.django_db -def test_thread_list_view_get_order_invalid(auth_client: APIClient): - response = auth_client.get("/chatbot/threads/?order_by=account") - assert response.status_code == 400 - - -@pytest.mark.django_db -def test_thread_list_view_post(auth_client: APIClient): - response = auth_client.post( - path="/chatbot/threads/", - data={"title": "Mock title"}, - format="json", - ) - - assert response.status_code == 201 - - thread_attrs = response.json() - - assert "id" in thread_attrs - assert "account" in thread_attrs - assert "created_at" in thread_attrs - assert Thread.objects.get(id=thread_attrs["id"]) - - -@pytest.mark.django_db -def test_thread_detail_view_delete(auth_client: APIClient, auth_user: Account): - thread = Thread.objects.create(account=auth_user) - response = auth_client.delete(f"/chatbot/threads/{thread.id}/") - assert response.status_code == 200 - - -@pytest.mark.django_db -def test_thread_detail_view_delete_not_found(auth_client: APIClient): - response = auth_client.delete(f"/chatbot/threads/{uuid.uuid4()}/") - assert response.status_code == 404 - - -@pytest.mark.django_db -def test_message_list_view_get(auth_client: APIClient, auth_user: Account): - thread = Thread.objects.create(account=auth_user) - - response = auth_client.get(f"/chatbot/threads/{thread.id}/messages/") - assert response.status_code == 200 - assert isinstance(response.json(), list) - - -@pytest.mark.django_db -def test_message_list_view_get_order_asc(auth_client: APIClient, auth_user: Account): - thread = Thread.objects.create(account=auth_user) - - for _ in range(2): - _ = MessagePair.objects.create( - thread=thread, - model_uri="gemini-2.0-flash", - user_message="mock message", - assistant_message="mock response", - ) - time.sleep(1) - - response = auth_client.get(f"/chatbot/threads/{thread.id}/messages/?order_by=created_at") - assert response.status_code == 200 - - messages = response.json() - - assert isinstance(messages, list) - - message_1_created_at = parse_datetime(messages[0]["created_at"]) - message_2_created_at = parse_datetime(messages[1]["created_at"]) - - assert message_1_created_at < message_2_created_at - - -@pytest.mark.django_db -def test_message_list_view_get_order_desc(auth_client: APIClient, auth_user: Account): - thread = Thread.objects.create(account=auth_user) - - for _ in range(2): - _ = MessagePair.objects.create( - thread=thread, - model_uri="gemini-2.0-flash", - user_message="mock message", - assistant_message="mock response", - ) - time.sleep(1) - - response = auth_client.get(f"/chatbot/threads/{thread.id}/messages/?order_by=-created_at") - assert response.status_code == 200 - - messages = response.json() - - assert isinstance(messages, list) - - message_1_created_at = parse_datetime(messages[0]["created_at"]) - message_2_created_at = parse_datetime(messages[1]["created_at"]) - - assert message_1_created_at > message_2_created_at - - -@pytest.mark.django_db -def test_message_list_view_get_not_found(auth_client: APIClient): - response = auth_client.get(f"/chatbot/threads/{uuid.uuid4()}/messages/") - assert response.status_code == 404 - - -@pytest.mark.django_db -def test_message_list_view_get_order_invalid(auth_client: APIClient, auth_user: Account): - thread = Thread.objects.create(account=auth_user) - response = auth_client.get(f"/chatbot/threads/{thread.id}/messages/?order_by=thread_id") - assert response.status_code == 400 - - -@pytest.mark.django_db -def test_message_list_view_post(monkeypatch, auth_client: APIClient, auth_user: Account): - monkeypatch.setattr(views, "_get_sql_agent", mock_get_sql_agent) - - thread = Thread.objects.create(account=auth_user) - - response = auth_client.post( - path=f"/chatbot/threads/{thread.id}/messages/", - data={"id": str(uuid.uuid4()), "content": "mock message"}, - format="json", - ) - - assert response.status_code == 201 - - response = auth_client.post( - path=f"/chatbot/threads/{thread.id}/messages/", - data={"content": "mock message"}, - format="json", - ) - - assert response.status_code == 201 - - -@pytest.mark.django_db -def test_message_list_view_post_bad_request(auth_client: APIClient, auth_user: Account): - thread = Thread.objects.create(account=auth_user) - - response = auth_client.post( - path=f"/chatbot/threads/{thread.id}/messages/", - data={"id": str(uuid.uuid4())}, - format="json", - ) - - assert response.status_code == 400 - - response = auth_client.post( - path=f"/chatbot/threads/{thread.id}/messages/", - data={"id": str(uuid.uuid4()), "content": []}, - format="json", - ) - - assert response.status_code == 400 - - -@pytest.mark.django_db -def test_message_list_view_post_not_found(auth_client: APIClient): - response = auth_client.post( - path=f"/chatbot/threads/{uuid.uuid4()}/messages/", - data={"id": str(uuid.uuid4()), "content": "mock message"}, - format="json", - ) - assert response.status_code == 404 - - -@pytest.mark.django_db -def test_feedback_list_view_put_create(monkeypatch, auth_client: APIClient, auth_user: Account): - monkeypatch.setattr(views, "LangSmithFeedbackSender", MockLangSmithFeedbackSender) - - thread = Thread.objects.create(account=auth_user) - - message_pairs = [ - MessagePair.objects.create( - thread=thread, - model_uri="gemini-2.0-flash", - user_message="mock message", - assistant_message="mock response", - ) - for _ in range(2) - ] - - response = auth_client.put( - path=f"/chatbot/message-pairs/{message_pairs[0].id}/feedbacks/", - data={"rating": 1, "comment": "good"}, - format="json", - ) - - assert response.status_code == 201 - - response = auth_client.put( - path=f"/chatbot/message-pairs/{message_pairs[1].id}/feedbacks/", - data={"rating": 1, "comment": None}, - format="json", - ) - - assert response.status_code == 201 - - -@pytest.mark.django_db -def test_feedback_list_view_put_update(monkeypatch, auth_client: APIClient, auth_user: Account): - monkeypatch.setattr(views, "LangSmithFeedbackSender", MockLangSmithFeedbackSender) - - thread = Thread.objects.create(account=auth_user) - - message_pair = MessagePair.objects.create( - thread=thread, - model_uri="gemini-2.0-flash", - user_message="mock message", - assistant_message="mock response", - ) - - _ = Feedback.objects.create(message_pair=message_pair, rating=0, comment="bad") - - response = auth_client.put( - path=f"/chatbot/message-pairs/{message_pair.id}/feedbacks/", - data={"rating": 1, "comment": "good"}, - format="json", - ) - - assert response.status_code == 200 - - -@pytest.mark.django_db -def test_feedback_list_view_put_bad_request(auth_client: APIClient, auth_user: Account): - thread = Thread.objects.create(account=auth_user) - - message_pair = MessagePair.objects.create( - thread=thread, - model_uri="gemini-2.0-flash", - user_message="mock message", - assistant_message="mock response", - ) - - response = auth_client.put( - path=f"/chatbot/message-pairs/{message_pair.id}/feedbacks/", - data={"comment": "good"}, - format="json", - ) - - assert response.status_code == 400 - - response = auth_client.put( - path=f"/chatbot/message-pairs/{message_pair.id}/feedbacks/", - data={"rating": 1, "comment": []}, - format="json", - ) - - assert response.status_code == 400 - - -@pytest.mark.django_db -def test_feedback_list_view_put_not_found(auth_client: APIClient, auth_user: Account): - response = auth_client.put( - path=f"/chatbot/message-pairs/{uuid.uuid4()}/feedbacks/", - data={"rating": 1, "comment": "good"}, - format="json", - ) - - assert response.status_code == 404 diff --git a/backend/apps/chatbot/tests/test_utils.py b/backend/apps/chatbot/tests/test_utils.py deleted file mode 100644 index b79171e34..000000000 --- a/backend/apps/chatbot/tests/test_utils.py +++ /dev/null @@ -1,88 +0,0 @@ -# -*- coding: utf-8 -*- -import json -from typing import Any - -from backend.apps.chatbot.utils.stream import _truncate_json - -STR_MAX_LEN = 300 -STR_LONG_LEN = 400 -STR_REMAINING = STR_LONG_LEN - STR_MAX_LEN - -LIST_MAX_LEN = 10 -LIST_LONG_LEN = 15 -LIST_REMAINING = LIST_LONG_LEN - LIST_MAX_LEN - - -def format_json(data: Any) -> str: - return json.dumps(data, ensure_ascii=False, indent=2) - - -def test_truncate_json_long_string(): - data = {"long_string": "a" * STR_LONG_LEN} - json_string = json.dumps(data) - truncated = _truncate_json(json_string, max_str_len=STR_MAX_LEN) - expected_str = "a" * STR_MAX_LEN + f"... ({STR_REMAINING} more characters)" - expected_json = format_json({"long_string": expected_str}) - assert truncated == expected_json - - -def test_truncate_json_long_list(): - data = {"long_list": list(range(LIST_LONG_LEN))} - json_string = json.dumps(data) - truncated = _truncate_json(json_string, max_list_len=LIST_MAX_LEN) - expected_list = list(range(LIST_MAX_LEN)) + [f"... ({LIST_REMAINING} more items)"] - expected_json = format_json({"long_list": expected_list}) - assert truncated == expected_json - - -def test_truncate_json_nested(): - data = { - "short_string": "a" * 100, - "nested_list": [ - {"short_string": "b" * 100, "long_string": "c" * STR_LONG_LEN, "int": 1, "float": 1.0} - for _ in range(LIST_LONG_LEN) - ], - "nested_dict": {"long_string": "d" * STR_LONG_LEN}, - } - json_string = json.dumps(data) - truncated = _truncate_json(json_string, max_list_len=LIST_MAX_LEN, max_str_len=STR_MAX_LEN) - expected_data = { - "short_string": "a" * 100, - "nested_list": [ - { - "short_string": "b" * 100, - "long_string": "c" * STR_MAX_LEN + f"... ({STR_REMAINING} more characters)", - "int": 1, - "float": 1.0, - } - for _ in range(LIST_MAX_LEN) - ] - + [f"... ({LIST_REMAINING} more items)"], - "nested_dict": { - "long_string": "d" * STR_MAX_LEN + f"... ({STR_REMAINING} more characters)" - }, - } - expected_json = format_json(expected_data) - assert truncated == expected_json - - -def test_truncate_json_not_dict(): - data = list(range(LIST_LONG_LEN)) - json_string = json.dumps(data) - truncated = _truncate_json(json_string) - assert truncated == json_string - - -def test_truncate_json_not_needed(): - data = { - "short_string": "hello", - "short_list": [1, 2, 3], - } - json_string = json.dumps(data) - expected_json = format_json(data) - assert _truncate_json(json_string) == expected_json - - -def test_truncate_json_invalid(): - invalid_json_string = '{"key": "value"' - assert _truncate_json(invalid_json_string) == invalid_json_string diff --git a/backend/apps/chatbot/urls.py b/backend/apps/chatbot/urls.py index 3cabc24b2..e216886da 100644 --- a/backend/apps/chatbot/urls.py +++ b/backend/apps/chatbot/urls.py @@ -1,19 +1,2 @@ # -*- coding: utf-8 -*- -from django.urls import path -from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView - -from .views import ( - FeedbackListView, - MessageListView, - ThreadDetailView, - ThreadListView, -) - -urlpatterns = [ - path("chatbot/token/", TokenObtainPairView.as_view()), - path("chatbot/token/refresh/", TokenRefreshView.as_view()), - path("chatbot/threads/", ThreadListView.as_view()), - path("chatbot/threads//", ThreadDetailView.as_view()), - path("chatbot/threads//messages/", MessageListView.as_view()), - path("chatbot/message-pairs//feedbacks/", FeedbackListView.as_view()), -] +urlpatterns = [] diff --git a/backend/apps/chatbot/utils/__init__.py b/backend/apps/chatbot/utils/__init__.py deleted file mode 100644 index 9ab1ed162..000000000 --- a/backend/apps/chatbot/utils/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- -from .stream import process_chunk - -__all__ = ["process_chunk"] diff --git a/backend/apps/chatbot/utils/gcloud.py b/backend/apps/chatbot/utils/gcloud.py deleted file mode 100644 index c9e8ecbf8..000000000 --- a/backend/apps/chatbot/utils/gcloud.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -import os -from functools import cache - -from google.cloud import bigquery as bq -from google.oauth2.service_account import Credentials - - -@cache -def get_chatbot_credentials() -> Credentials: - """Return cached Google Cloud service account credentials.""" - sa_file = os.getenv("CHATBOT_CREDENTIALS") - - if not sa_file: - raise ValueError( - "CHATBOT_CREDENTIALS environment variable must be set. " - "Please provide the path to your service account JSON file." - ) - - if not os.path.exists(sa_file): - raise FileNotFoundError(f"Service account file not found: {sa_file}") - - return Credentials.from_service_account_file(sa_file) - - -@cache -def get_bigquery_client() -> bq.Client: - """Return a cached BigQuery client. - - The client is initialized once using the project ID from the - `BIGQUERY_PROJECT_ID` environment variable and reused on subsequent calls. - - Returns: - bigquery.Client: A cached, authenticated BigQuery client. - """ - project = os.getenv("BIGQUERY_PROJECT_ID") - - if not project: - raise ValueError( - "BIGQUERY_PROJECT_ID environment variable must be set. " - "Please provide the ID of your BigQuery project." - ) - - return bq.Client( - project=project, - credentials=get_chatbot_credentials(), - ) diff --git a/backend/apps/chatbot/utils/stream.py b/backend/apps/chatbot/utils/stream.py deleted file mode 100644 index ad7278284..000000000 --- a/backend/apps/chatbot/utils/stream.py +++ /dev/null @@ -1,163 +0,0 @@ -# -*- coding: utf-8 -*- -import json -from typing import Any, Literal - -from langchain_core.messages import AIMessage, ToolMessage -from loguru import logger -from pydantic import UUID4, BaseModel - - -class ToolCall(BaseModel): - id: str - name: str - args: dict[str, Any] - - -class ToolOutput(BaseModel): - status: Literal["error", "success"] - tool_call_id: str - tool_name: str - output: str - metadata: dict[str, Any] | None = None - - -EventType = Literal[ - "tool_call", - "tool_output", - "final_answer", - "error", - "complete", -] - - -class EventData(BaseModel): - run_id: UUID4 | None = None - content: str | None = None - tool_calls: list[ToolCall] | None = None - tool_outputs: list[ToolOutput] | None = None - error_details: dict[str, Any] | None = None - - -class StreamEvent(BaseModel): - type: EventType - data: EventData - - def to_sse(self) -> str: - return self.model_dump_json() + "\n\n" - - -def _truncate_json(json_string: str, max_list_len: int = 10, max_str_len: int = 300) -> str: - """Iteratively truncates a serialized JSON object by shortening lists and strings - and adding human-readable placeholders. - - Note: - This function only processes JSON objects (dictionaries). If the serialized JSON - represents any other type, the original JSON string will be returned unchanged. - - Args: - json_string (str): The serialized JSON to process. - max_list_len (int, optional): The max number of items to keep in a list. Defaults to 10. - max_str_len (int, optional): The max length for any single string. Defaults to 300. - - Returns: - str: The truncated, formatted, and serialized JSON object. - """ - try: - data = json.loads(json_string) - except json.JSONDecodeError: - return json_string - - if not isinstance(data, dict): - return json_string - - stack = [data] - - while stack: - current_node = stack.pop() - - if isinstance(current_node, dict): - items_to_process = current_node.items() - else: - items_to_process = enumerate(current_node) - - for key_or_idx, item in items_to_process: - if isinstance(item, str): - if len(item) > max_str_len: - truncated_str = ( - item[:max_str_len] + f"... ({len(item) - max_str_len} more characters)" - ) - current_node[key_or_idx] = truncated_str - - elif isinstance(item, list): - if len(item) > max_list_len: - original_len = len(item) - del item[max_list_len:] - item.append(f"... ({original_len - max_list_len} more items)") - stack.append(item) - - elif isinstance(item, dict): - stack.append(item) - - return json.dumps(data, ensure_ascii=False, indent=2) - - -def process_chunk(chunk: dict[str, Any]) -> StreamEvent | None: - """Process a streaming chunk from a react agent workflow into a standardized StreamEvent. - - Args: - chunk (dict[str, Any]): Raw chunk from agent workflow. - Only processes "agent" and "tools" nodes. - - Returns: - StreamEvent | None: Structured event or None if the chunk is ignored: - - "tool_call" for agent messages with tool calls - - "tool_output" for tool execution results - - "final_answer" for agent messages without tool calls - - None for ignored chunks - """ - if "agent" in chunk: - ai_messages: list[AIMessage] = chunk["agent"]["messages"] - - # If no messages are returned, the model returned an empty response - # with no tool calls. This also counts as a final (but empty) answer. - if not ai_messages: - return StreamEvent(type="final_answer", data=EventData(content="")) - - message = ai_messages[0] - - if message.tool_calls: - event_type = "tool_call" - tool_calls = [ - ToolCall(id=tool_call["id"], name=tool_call["name"], args=tool_call["args"]) - for tool_call in message.tool_calls - ] - else: - event_type = "final_answer" - tool_calls = None - - # The content of an AIMessage can sometimes be a list, which is not the expected behavior. - # In that case, we just log a warning and cast it to string to keep processing consistent. - if isinstance(message.content, list): - logger.warning("[CHATBOT] Message content is of type 'list', casting to string") - content = "".join(str(part) for part in message.content) - else: - content = message.content - - event_data = EventData(content=content, tool_calls=tool_calls) - - return StreamEvent(type=event_type, data=event_data) - elif "tools" in chunk: - tool_messages: list[ToolMessage] = chunk["tools"]["messages"] - - tool_outputs = [ - ToolOutput( - status=message.status, - tool_call_id=message.tool_call_id, - tool_name=message.name, - output=_truncate_json(message.content), - ) - for message in tool_messages - ] - - return StreamEvent(type="tool_output", data=EventData(tool_outputs=tool_outputs)) - return None diff --git a/backend/apps/chatbot/views.py b/backend/apps/chatbot/views.py deleted file mode 100644 index d65d5c257..000000000 --- a/backend/apps/chatbot/views.py +++ /dev/null @@ -1,466 +0,0 @@ -# -*- coding: utf-8 -*- -import os -import uuid -from collections.abc import Generator -from contextlib import contextmanager -from functools import cache -from typing import Any, Iterator, Type, TypedDict, TypeVar - -from django.http import StreamingHttpResponse -from google.api_core import exceptions as google_api_exceptions -from langchain.chat_models import init_chat_model -from langchain_core.messages import RemoveMessage -from langchain_core.messages.utils import count_tokens_approximately, trim_messages -from langgraph.checkpoint.postgres import PostgresSaver -from langgraph.errors import GraphRecursionError -from langgraph.graph.message import REMOVE_ALL_MESSAGES -from loguru import logger -from rest_framework import exceptions, status -from rest_framework.parsers import JSONParser -from rest_framework.permissions import IsAuthenticated -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.serializers import Serializer -from rest_framework.views import APIView - -from backend.apps.chatbot.agent.prompts import SQL_AGENT_SYSTEM_PROMPT -from backend.apps.chatbot.agent.react_agent import ReActAgent -from backend.apps.chatbot.agent.tools import get_tools -from backend.apps.chatbot.agent.types import StateT -from backend.apps.chatbot.feedback_sender import LangSmithFeedbackSender -from backend.apps.chatbot.mock import allow_agent_mock -from backend.apps.chatbot.models import Feedback, MessagePair, Thread -from backend.apps.chatbot.serializers import ( - FeedbackCreateSerializer, - FeedbackSerializer, - MessagePairSerializer, - ThreadCreateSerializer, - ThreadSerializer, - UserMessageSerializer, -) -from backend.apps.chatbot.utils.gcloud import get_chatbot_credentials -from backend.apps.chatbot.utils.stream import EventData, StreamEvent, process_chunk - -ModelSerializer = TypeVar("ModelSerializer", bound=Serializer) - -# Model name/URI. Refer to the LangChain docs for valid names/URIs -# https://python.langchain.com/api_reference/langchain/chat_models/langchain.chat_models.base.init_chat_model.html -MODEL_URI = "google_vertexai:gemini-2.5-flash" - -# Gemini models have a ~1 million tokens context window -CONTEXT_WINDOW = 2**20 - -# Maximum number of tokens allowed at the START of a conversation turn -MAX_TOKENS = CONTEXT_WINDOW // 2 - -# Generic error message for unexpected errors when calling the agent -ERROR_MESSAGE_UNEXPECTED = ( - "Ops, algo deu errado! Ocorreu um erro inesperado. Por favor, tente novamente. " - "Se o problema persistir, avise-nos. Obrigado pela paciência!" -) - - -class ConfigDict(TypedDict): - run_id: str - recursion_limit: int - configurable: dict[str, Any] - - -class ThreadListView(APIView): - permission_classes = [IsAuthenticated] - ordering_fields = {"created_at", "-created_at"} - - def get(self, request: Request) -> Response: - """Retrieve all threads associated with the authenticated user. - - Args: - request (Request): A Django REST framework `Request` object - containing the authenticated user. - - Returns: - Response: A JSON response containing a list of serialized threads. - """ - threads = Thread.objects.filter(account=request.user, deleted=False) - - field = request.query_params.get("order_by") - - if field is not None: - if field not in self.ordering_fields: - return Response( - {"detail": f"Invalid order_by field: {field}"}, - status=status.HTTP_400_BAD_REQUEST, - ) - threads = threads.order_by(field) - - serializer = ThreadSerializer(threads, many=True) - return Response(serializer.data) - - def post(self, request: Request) -> Response: - """Create a new thread for the authenticated user. - - Args: - request (Request): A Django REST framework `Request` object - containing the authenticated user. - - Returns: - Response: A JSON response containing the serialized newly created thread. - """ - serializer = _validate(request, ThreadCreateSerializer) - - title = serializer.validated_data["title"] - - thread = Thread.objects.create(account=request.user, title=title) - serializer = ThreadSerializer(thread) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - -class ThreadDetailView(APIView): - permission_classes = [IsAuthenticated] - - def delete(self, request: Request, thread_id: uuid.UUID) -> Response: - """Soft delete a thread and hard delete all its checkpoints. - - Args: - request (Request): A Django REST framework `Request` object. - thread_id (uuid.UUID): The unique identifier of the thread. - - Returns: - Response: A JSON response indicating success (200) or failure (500). - """ - thread = _get_thread_by_id(thread_id) - - logger.info(f"[CHATBOT] Deleting thread {thread_id}") - - try: - thread.deleted = True - thread.save() - with _get_sql_agent() as agent: - agent.clear_thread(str(thread.id)) - logger.success(f"[CHATBOT] Thread {thread.id} deleted successfully") - return Response({"detail": "Thread deleted successfully"}) - except Exception: - logger.exception(f"[CHATBOT] Error deleting thread {thread.id}:") - return Response( - {"detail": "Error deleting thread"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - - -class MessageListView(APIView): - permission_classes = [IsAuthenticated] - ordering_fields = {"created_at", "-created_at"} - - def get(self, request: Request, thread_id: uuid.UUID) -> Response: - """Retrieve all message pairs associated with a specific thread. - - Args: - request (Request): A Django REST framework `Request` object. - thread_id (uuid.UUID): The unique identifier of the thread. - - Returns: - Response: A JSON response containing the serialized message pairs. - """ - thread = _get_thread_by_id(thread_id) - - message_pairs = MessagePair.objects.filter(thread=thread) - - field = request.query_params.get("order_by") - - if field is not None: - if field not in self.ordering_fields: - return Response( - {"detail": f"Invalid order_by field: {field}"}, - status=status.HTTP_400_BAD_REQUEST, - ) - message_pairs = message_pairs.order_by(field) - - serializer = MessagePairSerializer(message_pairs, many=True) - return Response(serializer.data) - - def post(self, request: Request, thread_id: uuid.UUID) -> Response: - """Create a message pair for a given thread. - - Args: - request (Request): A Django REST framework `Request` object containing a user message. - thread_id (uuid.UUID): The unique identifier for the thread. - - Returns: - Response: A JSON response with the serialized message pair object. - """ - thread = _get_thread_by_id(thread_id) - - run_id = str(uuid.uuid4()) - - config = ConfigDict( - run_id=run_id, - recursion_limit=32, - configurable={"thread_id": str(thread.id), "user_id": str(thread.account.uuid)}, - ) - - serializer = _validate(request, UserMessageSerializer) - - message = serializer.validated_data["content"] - - return StreamingHttpResponse( - _stream_sql_agent_response( - message=message, - config=config, - thread=thread, - ), - status=status.HTTP_201_CREATED, - headers={ - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - }, - content_type="text/event-stream", - ) - - -class FeedbackListView(APIView): - permission_classes = [IsAuthenticated] - - def put(self, request: Request, message_pair_id: uuid.UUID) -> Response: - """Create or update a feedback for a given message pair. - - Args: - request (Request): A Django REST framework `Request` object containing feedback data. - message_pair_id (uuid.UUID): The unique identifier of the message pair. - - Returns: - Response: A JSON response with the serialized feedback object and an appropriate - HTTP status code (201 for creation, 200 for update). - """ - serializer = _validate(request, FeedbackCreateSerializer) - - message_pair = _get_message_pair_by_id(message_pair_id) - - try: - feedback = Feedback.objects.get(message_pair=message_pair) - feedback.user_update(serializer.validated_data) - created = False - except Feedback.DoesNotExist: - feedback = Feedback.objects.create( - message_pair=message_pair, **serializer.validated_data - ) - created = True - - feedback_sender = _get_feedback_sender() - feedback_sender.send_feedback(feedback, created) - - serializer = FeedbackSerializer(feedback) - - status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK - - return Response(serializer.data, status=status_code) - - -@cache -def _get_feedback_sender() -> LangSmithFeedbackSender: - """Provide a `LangSmithFeedbackSender` feedback sender. - - Returns: - LangSmithFeedbackSender: An instance of `LangSmithFeedbackSender`. - """ - return LangSmithFeedbackSender() - - -@contextmanager -def _get_sql_agent() -> Generator[ReActAgent]: - """Provide a configured ReAct agent. - - Yields: - Iterator[CompiledGraph]: An instance of `CompiledGraph`. - """ - db_host = os.environ["DB_HOST"] - db_port = os.environ["DB_PORT"] - db_name = os.environ["DB_NAME"] - db_user = os.environ["DB_USER"] - db_password = os.environ["DB_PASSWORD"] - - conn = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}" - - credentials = get_chatbot_credentials() - - model = init_chat_model( - MODEL_URI, - api_transport="rest", # gRPC (the default for ChatVertexAI) is not compatible with gevent - temperature=0.2, - credentials=credentials, - ) - - def start_hook(state: StateT): - messages = state["messages"] - - # For the first message, skip trimming. If it's too long, let it fail. - if len(messages) == 1: - return {"messages": []} - - # For subsequent turns, trim chat history to fit within token limits. - remaining_messages = trim_messages( - messages, - token_counter=count_tokens_approximately, # The accurate counter is too slow. - max_tokens=MAX_TOKENS, - strategy="last", - start_on="human", - end_on="human", - include_system=True, - allow_partial=False, - ) - - return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *remaining_messages]} - - with PostgresSaver.from_conn_string(conn) as checkpointer: - checkpointer.setup() - - sql_agent = ReActAgent( - model=model, - tools=get_tools(), - start_hook=start_hook, - prompt=SQL_AGENT_SYSTEM_PROMPT, - checkpointer=checkpointer, - ) - - yield sql_agent - - -@allow_agent_mock -def _stream_sql_agent_response(message: str, config: ConfigDict, thread: Thread) -> Iterator[str]: - """Stream agent's execution progress. - - Args: - message (str): User's input message. - config (ConfigDict): Configuration for agent execution. - thread (Thread): Conversation thread. - - Yields: - Iterator[str]: SSE-formatted event. - """ - events = [] - agent_state = None - - try: - logger.info("[CHATBOT] Calling SQL Agent") - with _get_sql_agent() as agent: - for mode, chunk in agent.stream( - message=message, - config=config, - stream_mode=["updates", "values"], - ): - if mode == "values": - agent_state = chunk - continue - - event = process_chunk(chunk) - - if event is not None: - if event.type == "final_answer": - assistant_message = event.data.content - error_message = None - events.append(event.model_dump()) - yield event.to_sse() - - # The last event always contains the agent's final answer, - # so we use it to save the message pair in the database - logger.success("[CHATBOT] SQL Agent called successfully") - except GraphRecursionError: - logger.exception(f"[CHATBOT] Graph recursion error for message {config['run_id']}:") - except google_api_exceptions.InvalidArgument: - logger.exception("[CHATBOT] Agent execution failed with Google API InvalidArgument error:") - - assistant_message = None - error_message = ERROR_MESSAGE_UNEXPECTED - - if agent_state is not None: - model = init_chat_model(MODEL_URI, temperature=0) - total_tokens = model.get_num_tokens_from_messages(agent_state["messages"]) - - if total_tokens >= CONTEXT_WINDOW: - error_message = ( - "Sua última mensagem ultrapassou o limite de tamanho para esta conversa. " - "Por favor, tente dividir sua solicitação em partes menores " - "ou inicie uma nova conversa." - ) - - yield StreamEvent( - type="error", data=EventData(error_details={"message": error_message}) - ).to_sse() - except Exception: - logger.exception(f"[CHATBOT] Unexpected error responding message {config['run_id']}:") - assistant_message = None - error_message = ERROR_MESSAGE_UNEXPECTED - - yield StreamEvent( - type="error", data=EventData(error_details={"message": error_message}) - ).to_sse() - - message_pair = MessagePair.objects.create( - id=config["run_id"], - thread=thread, - model_uri=MODEL_URI, - user_message=message, - assistant_message=assistant_message, - error_message=error_message, - events=events, - ) - logger.success(f"[CHATBOT] Message pair {message_pair.id} saved successfully") - - yield StreamEvent(type="complete", data=EventData(run_id=message_pair.id)).to_sse() - - -def _get_thread_by_id(thread_id: uuid.UUID) -> Thread: - """Retrieve a `Thread` object by its ID. - - Args: - message_pair_id (uuid.UUID): The unique identifier of the `Thread`. - - Raises: - NotFound: If no `Thread` exists with the given ID. - - Returns: - Thread: The retrieved `Thread` object. - """ - try: - return Thread.objects.get(id=thread_id, deleted=False) - except Thread.DoesNotExist: - raise exceptions.NotFound - - -def _get_message_pair_by_id(message_pair_id: uuid.UUID) -> MessagePair: - """Retrieve a `MessagePair` object by its ID. - - Args: - message_pair_id (uuid.UUID): The unique identifier of the `MessagePair`. - - Raises: - NotFound: If no `MessagePair` exists with the given ID. - - Returns: - MessagePair: The retrieved `MessagePair` object. - """ - try: - return MessagePair.objects.get(id=message_pair_id) - except MessagePair.DoesNotExist: - raise exceptions.NotFound - - -def _validate(request: Request, model_serializer: Type[ModelSerializer]) -> ModelSerializer: - """ - Parse and validate the JSON payload from a request using a Django REST framework serializer. - - Args: - request (Request): A Django REST framework `Request` object containing JSON data. - model_serializer (Type[ModelSerializer]): A serializer class used to validate the data. - - Raises: - exceptions.ValidationError: If the request data fails serializer validation. - - Returns: - ModelSerializer: An instance of the serializer populated with validated data. - """ - data = JSONParser().parse(request) - - serializer = model_serializer(data=data) - - if not serializer.is_valid(): - raise exceptions.ValidationError(serializer.errors) - - return serializer From c83205ee378adb154d7ab6e2471ac007c1c0fc2f Mon Sep 17 00:00:00 2001 From: vrtornisiello Date: Mon, 30 Mar 2026 10:54:27 -0300 Subject: [PATCH 3/3] chore: add migration for removing chatbot tables --- .../migrations/0009_remove_chatbot_tables.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 backend/apps/chatbot/migrations/0009_remove_chatbot_tables.py diff --git a/backend/apps/chatbot/migrations/0009_remove_chatbot_tables.py b/backend/apps/chatbot/migrations/0009_remove_chatbot_tables.py new file mode 100644 index 000000000..7e7f95e76 --- /dev/null +++ b/backend/apps/chatbot/migrations/0009_remove_chatbot_tables.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.10 on 2026-03-30 13:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0008_remove_messagepair_generated_chart_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='messagepair', + name='thread', + ), + migrations.RemoveField( + model_name='thread', + name='account', + ), + migrations.DeleteModel( + name='Feedback', + ), + migrations.DeleteModel( + name='MessagePair', + ), + migrations.DeleteModel( + name='Thread', + ), + migrations.RunSQL( + sql="DROP TABLE IF EXISTS checkpoint_blobs;", + reverse_sql=migrations.RunSQL.noop, + ), + migrations.RunSQL( + sql="DROP TABLE IF EXISTS checkpoint_writes;", + reverse_sql=migrations.RunSQL.noop, + ), + migrations.RunSQL( + sql="DROP TABLE IF EXISTS checkpoints;", + reverse_sql=migrations.RunSQL.noop, + ), + migrations.RunSQL( + sql="DROP TABLE IF EXISTS checkpoint_migrations;", + reverse_sql=migrations.RunSQL.noop, + ), + ]