diff --git a/backend/apps/chatbot/admin.py b/backend/apps/chatbot/admin.py index 479ed253..3578af36 100644 --- a/backend/apps/chatbot/admin.py +++ b/backend/apps/chatbot/admin.py @@ -13,6 +13,7 @@ class ThreadAdmin(admin.ModelAdmin): ] ordering = ["-created_at"] + class MessagePairAdmin(admin.ModelAdmin): list_display = [field.name for field in MessagePair._meta.fields] readonly_fields = list_display @@ -34,6 +35,7 @@ class FeedbackAdmin(admin.ModelAdmin): ] 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/prompts.py b/backend/apps/chatbot/agent/prompts.py index 0bfb5d18..5a907a9f 100644 --- a/backend/apps/chatbot/agent/prompts.py +++ b/backend/apps/chatbot/agent/prompts.py @@ -10,15 +10,31 @@ - **search_datasets:** Para buscar datasets relacionados à pergunta do usuário. - **get_dataset_details:** Para obter informações detalhadas sobre um dataset específico. - **execute_bigquery_sql:** Para executar consultas SQL nas tabelas disponíveis. -- **decode_table_values:** Para decodificar valores codificados presentes nas tabelas. -- **inspect_column_values:** Para inspecionar colunas das tabelas, caso a ferramenta `decode_table_values` não retorne resultados. +- **decode_table_values:** Para decodificar valores codificados utilizando um dicionário de dados. --- # Regras de Execução (CRÍTICO) -1. Toda vez que você utilizar uma ferramenta, você **DEVE** escrever um resumo do seu raciocínio. +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. + +--- + +# 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. --- @@ -37,7 +53,7 @@ - **Geográfico**: `sigla_uf` (estado), `id_municipio` (município). - **Temporal**: `ano` (ano). - **Identificadores**: `id_*`, `codigo_*`, `sigla_*`. -- **Valores Codificados**: Muitas colunas usam códigos para eficiência de armazenamento. **Sempre** priorize a ferramenta `decode_table_values` para entendê-los. Use a ferramenta `inspect_column_values` **apenas** como uma alternativa para exploração. +- **Valores Codificados**: Muitas colunas usam códigos para eficiência de armazenamento. **Sempre** utilize a ferramenta `decode_table_values` para decodificá-los. --- @@ -75,12 +91,13 @@ --- # Resposta Final -Quando você estiver pronto para responder ao usuário, sua resposta **DEVE** seguir a estrutura abaixo: -- **Resumo dos Resultados:** Comece com um resumo claro e conciso da resposta. -- **Contexto:** Explique o que os dados representam. Mencione a organização fonte (ex: "Dados do Censo 2010 do IBGE..."), o período de tempo e o nível geográfico. -- **Dados:** Apresente os dados e cálculos realizados com clareza. Utilize Markdown. Exiba valores nulos/vazios como "N/D". -- **Insights:** Destaque 1-3 pontos ou padrões importantes e não óbvios dos resultados. -- **Próximos Passos:** Proponha uma pergunta de acompanhamento relevante, um dataset relacionado para explorar, ou uma forma de refinar a análise atual. +Ao redigir a resposta final, construa um texto explicativo e fluido. 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 a conclusão principal de forma direta e clara. +2. Em seguida, explique a origem e o escopo dos dados (ex: "Esses números são do Censo Escolar de 2021, realizado pelo INEP..."), incluindo o período de tempo e o nível geográfico. +3. Mostre os dados ou os resultados da sua análise de forma organizada. O uso de Markdown para tabelas ou listas é apropriado aqui. Exiba valores nulos/vazios como "N/D". +4. Após apresentar os dados, comente de 1 a 3 observações ou padrões interessantes que podem não ser óbvios à primeira vista. +5. Conclua sugerindo uma forma de aprofundar a análise, seja com uma nova pergunta, explorando um dataset relacionado, ou adicionando um novo filtro. --- diff --git a/backend/apps/chatbot/agent/react_agent.py b/backend/apps/chatbot/agent/react_agent.py index 1d97df9b..61739276 100644 --- a/backend/apps/chatbot/agent/react_agent.py +++ b/backend/apps/chatbot/agent/react_agent.py @@ -77,7 +77,7 @@ def _call_model(self, state: State, config: RunnableConfig) -> dict[str, list[Ba response: AIMessage = self.model_runnable.invoke(messages, config) if not response.content and not response.tool_calls: - logger.warning("Received empty response from model, skipping message list update") + logger.warning("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: @@ -114,7 +114,7 @@ async def _acall_model( response: AIMessage = await self.model_runnable.ainvoke(messages, config) if not response.content and not response.tool_calls: - logger.warning("Received empty response from model, skipping message list update") + logger.warning("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: diff --git a/backend/apps/chatbot/agent/tools.py b/backend/apps/chatbot/agent/tools.py index 4a94dce8..0a3b6d7e 100644 --- a/backend/apps/chatbot/agent/tools.py +++ b/backend/apps/chatbot/agent/tools.py @@ -20,9 +20,6 @@ # Maximum number of datasets returned on search PAGE_SIZE = 10 -# 5GB limit for inspection queries -LIMIT_INSPECTION_QUERY = int(5 * 1e9) - # 10GB limit for other queries LIMIT_BIGQUERY_QUERY = int(10 * 1e9) @@ -441,27 +438,18 @@ def execute_bigquery_sql(sql_query: str) -> str: Returns: str: Query results as JSON array. Empty results return "[]". """ # noqa: E501 - forbidden_commands = [ - "CREATE", - "ALTER", - "DROP", - "TRUNCATE", - "INSERT", - "UPDATE", - "DELETE", - "GRANT", - "REVOKE", - ] + client = get_bigquery_client() - for command in forbidden_commands: - if command in sql_query.upper(): - raise ToolError( - message=f"Query aborted: Command {command} is forbidden.", - error_type="FORBIDDEN_COMMAND", - instructions="Your access is strictly read-only. Use only SELECT statements.", - ) + 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 - client = get_bigquery_client() + 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.", + ) job_config = bq.QueryJobConfig(maximum_bytes_billed=LIMIT_BIGQUERY_QUERY) query_job = client.query(sql_query, job_config=job_config) @@ -475,11 +463,7 @@ def execute_bigquery_sql(sql_query: str) -> str: @tool @handle_tool_errors( - instructions={ - GoogleAPIError.NOT_FOUND: ( - "Dictionary table not found for this dataset. Use `inspect_column_values` instead." - ) - } + instructions={GoogleAPIError.NOT_FOUND: ("Dictionary table not found for this dataset.")} ) def decode_table_values(table_gcp_id: str, column_name: str | None = None) -> str: """Decode coded values from a table. @@ -489,7 +473,7 @@ def decode_table_values(table_gcp_id: str, column_name: str | None = None) -> st the authoritative meanings of these codes. Args: - table_gcp_id (str): Full BigQuery table reference + 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`. @@ -529,42 +513,6 @@ def decode_table_values(table_gcp_id: str, column_name: str | None = None) -> st return json.dumps(tool_output, ensure_ascii=False, default=str) -@tool -@handle_tool_errors( - instructions={ - GoogleAPIError.BYTES_BILLED_LIMIT_EXCEEDED: ( - "Use `execute_bigquery_sql` with a WHERE clause " - "to inspect a sample of the column's values" - ) - } -) -def inspect_column_values(table_gcp_id: str, column_name: str) -> str: - """Show actual distinct values in a column. - - FALLBACK tool to use when `search_dictionary_table()` fails. - Useful for understanding data patterns and planning `WHERE` clauses. - - Args: - table_gcp_id (str): Full BigQuery table reference - column_name (str): Column name from `get_dataset_details()` - - Returns: - str: JSON array of distinct values. - """ # noqa: E501 - client = get_bigquery_client() - - sql_query = f"SELECT DISTINCT {column_name} FROM {table_gcp_id}" - - job_config = bq.QueryJobConfig(maximum_bytes_billed=LIMIT_INSPECTION_QUERY) - query_job = client.query(sql_query, job_config=job_config) - - rows = query_job.result() - results = [row.get(column_name) 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. @@ -577,12 +525,10 @@ def get_tools() -> list[BaseTool]: - 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 - - inspect_column_values: Inspect actual column values as fallback """ return [ search_datasets, get_dataset_details, execute_bigquery_sql, decode_table_values, - inspect_column_values, ] diff --git a/backend/apps/chatbot/apps.py b/backend/apps/chatbot/apps.py index a3bb5799..ce6b6312 100644 --- a/backend/apps/chatbot/apps.py +++ b/backend/apps/chatbot/apps.py @@ -11,4 +11,5 @@ class ChatbotConfig(AppConfig): def ready(self): # Enable logs from the chatbot package import chatbot + logger.enable(chatbot.__name__) diff --git a/backend/apps/chatbot/authentication.py b/backend/apps/chatbot/authentication.py index 40209d94..9c677197 100644 --- a/backend/apps/chatbot/authentication.py +++ b/backend/apps/chatbot/authentication.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from backend.apps.account.models import Account diff --git a/backend/apps/chatbot/migrations/0002_alter_feedback_comment.py b/backend/apps/chatbot/migrations/0002_alter_feedback_comment.py index 0f79b964..d4c62600 100644 --- a/backend/apps/chatbot/migrations/0002_alter_feedback_comment.py +++ b/backend/apps/chatbot/migrations/0002_alter_feedback_comment.py @@ -1,18 +1,18 @@ +# -*- coding: utf-8 -*- # Generated by Django 4.2.20 on 2025-04-17 14:05 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('chatbot', '0001_initial'), + ("chatbot", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='feedback', - name='comment', + model_name="feedback", + name="comment", field=models.TextField(blank=True, null=True), ), ] diff --git a/backend/apps/chatbot/utils/stream.py b/backend/apps/chatbot/utils/stream.py index e0f48c21..dd4f4b28 100644 --- a/backend/apps/chatbot/utils/stream.py +++ b/backend/apps/chatbot/utils/stream.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- import json -from typing import Any, Literal, Optional +from typing import Any, Literal from langchain_core.messages import AIMessage, ToolMessage +from loguru import logger from pydantic import UUID4, BaseModel @@ -30,11 +31,11 @@ class ToolOutput(BaseModel): class EventData(BaseModel): - run_id: Optional[UUID4] = None - content: Optional[str] = None - tool_calls: Optional[list[ToolCall]] = None - tool_outputs: Optional[list[ToolOutput]] = None - error_details: Optional[dict[str, Any]] = None + 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): @@ -125,15 +126,26 @@ def process_chunk(chunk: dict[str, Any]) -> StreamEvent | None: 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 ] - event_type = "tool_call" - event_data = EventData(content=message.content, tool_calls=tool_calls) else: event_type = "final_answer" - event_data = EventData(content=message.content) + 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 not isinstance(message.content, str): + logger.warning( + f"Message content is of type '{type(message.content)}', casting to string" + ) + content = str(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: diff --git a/backend/settings/base.py b/backend/settings/base.py index 8123c520..1fd22d55 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -84,7 +84,7 @@ SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(days=1), "REFRESH_TOKEN_LIFETIME": timedelta(days=7), - "USER_AUTHENTICATION_RULE": "backend.apps.chatbot.authentication.authentication_rule" + "USER_AUTHENTICATION_RULE": "backend.apps.chatbot.authentication.authentication_rule", } MIDDLEWARE = [