Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ GOOGLE_GCS_BUCKET=bucket-name
# ============================================================
# == LLM settings ==
# ============================================================
MODEL_URI=google_genai:gemini-3-flash-preview
MODEL_TEMPERATURE=0.2
THINKING_LEVEL=low
OPENAI_API_KEY=openai-api-key
MODEL_URI=gpt-5.6-luna
REASONING_EFFORT=medium

# ============================================================
# == LangSmith settings ==
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test-chatbot.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ jobs:
GOOGLE_GCS_BUCKET: mock-bucket

# Mock LLM configuration
OPENAI_API_KEY: mock-openai-api-key
MODEL_URI: mock-model-uri
MODEL_TEMPERATURE: 0.0
THINKING_LEVEL: low
REASONING_EFFORT: medium

# Mock LangSmith configuration
LANGSMITH_TRACING: false
Expand Down
12 changes: 2 additions & 10 deletions app/agent/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,8 @@

from langchain.agents.middleware import ModelRequest, dynamic_prompt

from app.agent.context import AgentContext
from app.i18n import language_directive


@dynamic_prompt
def system_prompt_middleware(request: ModelRequest) -> str:
"""Render the system prompt template, filling `{current_date}` and `{language_directive}`."""
context: AgentContext = request.runtime.context

return request.system_message.content.format(
current_date=date.today().isoformat(),
language_directive=language_directive(context.language),
)
"""Render the system prompt template, filling `{current_date}`."""
return request.system_message.content.format(current_date=date.today().isoformat())
164 changes: 54 additions & 110 deletions app/agent/prompts.py

Large diffs are not rendered by default.

50 changes: 4 additions & 46 deletions app/agent/schemas.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,6 @@
from enum import Enum

from pydantic import BaseModel, Field


class TemporalGranularity(str, Enum):
"""Granularity of the data's temporal coverage."""

DAY = "day"
MONTH = "month"
YEAR = "year"


class TemporalCoverage(BaseModel):
"""The interval the SQL query actually filtered on in the answer."""

period_start: str = Field(
description=(
"Start of the interval filtered by the SQL query (e.g. '2010' for "
"`ano = 2010` or `ano BETWEEN 2010 AND 2012`). Format it to match `granularity`: "
"YYYY (year), YYYY-MM (month) or YYYY-MM-DD (day) — e.g. '2010', '2010-01', '2010-01-01'. "
"May be narrower than the table's full coverage."
)
)
period_end: str = Field(
description=(
"End of the interval filtered by the SQL query (e.g. '2010' for "
"`ano = 2010`; '2012' for `ano BETWEEN 2010 AND 2012`). Format it to match `granularity`: "
"YYYY (year), YYYY-MM (month) or YYYY-MM-DD (day) — e.g. '2012', '2012-01', '2012-01-01'. "
"May be narrower than the table's full coverage."
)
)
granularity: TemporalGranularity = Field(
description=(
"Granularity of `period_start`/`period_end`, matching their format: "
"YYYY (year), YYYY-MM (month), YYYY-MM-DD (day)."
)
)


class DataSource(BaseModel):
"""A Base dos Dados table the answer draws on or points the user to."""

Expand Down Expand Up @@ -73,16 +36,11 @@ class StructuredResponse(BaseModel):
"on clarification turns. Leave empty (None) when no table is relevant."
),
)
temporal_coverage: TemporalCoverage | None = Field(
default=None,
description=(
"The interval your SQL query actually filtered. Leave empty (None) when no query "
"was run (e.g. a clarification turn) or the answer has no temporal dimension."
),
)
follow_up_questions: list[str] | None = Field(
follow_up_prompts: list[str] | None = Field(
default=None,
description=(
"3 suggested follow-up questions (in the user's language) to explore the data further."
"3 next prompts the user could send you to explore the data further, each written "
"in the user's own voice — a message the user types to you, never a question you ask "
"the user. In the user's language."
),
)
5 changes: 1 addition & 4 deletions app/agent/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
from langchain_core.tools import BaseTool

from app.agent.tools.api import get_dataset_details, get_table_details, search_datasets
from app.agent.tools.bigquery import (
decode_table_values,
execute_bigquery_sql,
)
from app.agent.tools.bigquery import decode_table_values, execute_bigquery_sql


class BDToolkit:
Expand Down
54 changes: 12 additions & 42 deletions app/agent/tools/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,25 +72,14 @@ async def _fetch_usage_guide(gcp_dataset_id: str, language: LanguageCode) -> str
@tool
@handle_tool_errors
async def search_datasets(query: str, runtime: ToolRuntime[AgentContext]) -> str:
"""Search for datasets in Base dos Dados using keywords.

CRITICAL: Use individual KEYWORDS only, not full sentences. The search engine uses Elasticsearch.
"""Search Base dos Dados datasets (Elasticsearch).

Args:
query (str): 2-3 keywords maximum. Use Portuguese terms, organization names, or dataset names.
Good Examples: "censo", "rais", "ibge", "inep", "educacao", "saude".
Avoid: "Brazilian population data by municipality".
query (str): 1-3 keywords, never a sentence — a dataset or organization name,
else a theme. Start with one keyword; broaden only if it returns empty.

Returns:
str: JSON array of datasets. If empty/irrelevant results, try different keywords.

Strategy: hierarchical funnel — ALWAYS start with a SINGLE keyword and broaden a level only if it returns nothing:
1. Dataset name ("censo", "rais", "enem") or organization ("ibge", "inep", "tse").
2. Core theme ("educacao", "saude", "economia", "emprego").
3. English term ("health", "education").
4. A 2-3 word combination only if the levels above fail ("saude ms", "censo municipio").

Next step: Use `get_dataset_details()` with returned dataset IDs.
JSON array of datasets (id, name, description, organizations, tags, themes).
"""
response = await _client.get(
url=SEARCH_URL,
Expand Down Expand Up @@ -128,23 +117,14 @@ async def search_datasets(query: str, runtime: ToolRuntime[AgentContext]) -> str
async def get_dataset_details(
dataset_id: str, runtime: ToolRuntime[AgentContext]
) -> str:
"""Get comprehensive details about a specific dataset including all its tables.

Use AFTER `search_datasets()` to understand data structure before writing queries.
"""Get a dataset's tables and metadata by its id.

Args:
dataset_id (str): Dataset ID obtained from `search_datasets()`.
This is a UUID-like string, not the human-readable name.
dataset_id (str): Dataset UUID from `search_datasets()`.

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`).
- table descriptions explaining what each table contains.
- usage_guide: Provide key information and best practices for using the dataset.

Next step: Use `get_table_details()` with returned table IDs.
JSON object — dataset metadata, a `usage_guide`, and `tables`,
each with its `gcp_id` (`project.dataset.table`), name, and description.
"""
response = await _client.post(
url=GRAPHQL_URL,
Expand Down Expand Up @@ -248,24 +228,14 @@ async def get_dataset_details(
@tool
@handle_tool_errors
async def get_table_details(table_id: str, runtime: ToolRuntime[AgentContext]) -> str:
"""Get comprehensive details about a specific table including all its columns.

Use AFTER `get_dataset_details()` to understand table structure before writing queries.
"""Get a table's schema and metadata by its id.

Args:
table_id (str): Table ID obtained from `get_dataset_details()`.
This is typically a UUID-like string, not the human-readable name.
table_id (str): Table UUID from `get_dataset_details()`.

Returns:
str: JSON object with complete table information, including:
- Basic metadata (name, description).
- gcp_id: Full BigQuery table reference (`project.dataset.table`).
- columns: All column names, types, and descriptions, including
`needs_decoding` and `reference_table_id` for coded columns.
- partitioned_by: Columns to filter on for cost control.
- period_start / period_end: First and last period covered by the table.
Format varies (`2024`, `'2026-04-12'`, etc.) — use the value verbatim,
matched to the appropriate temporal column (`ano`, `data`, etc.).
JSON object — table metadata, `gcp_id`, `period_start`/`period_end`, `partitioned_by`, and
`columns` (name, type, description, and the `needs_decoding` / `reference_table_id` flags).
"""
response = await _client.post(
url=GRAPHQL_URL,
Expand Down
64 changes: 19 additions & 45 deletions app/agent/tools/bigquery.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,34 +35,16 @@ def _bq_client() -> bq.Client: # pragma: no cover
def execute_bigquery_sql(
sql_query: str, slug: str, runtime: ToolRuntime[AgentContext]
) -> tuple[str, dict[str, Any] | None]:
"""Execute a SQL query against BigQuery tables from the Base dos Dados database.

PRECONDITION — only call this when the question is already specific enough to
answer with data. For a broad/exploratory question (a bare topic) or one that
references an entity the user did not name, do NOT call this tool: explore the
metadata and ask the user to refine the question first.

Use AFTER identifying the right datasets and understanding tables structure.
It includes a 10GB processing limit for safety.
"""Run one read-only GoogleSQL query against Base dos Dados (10GB scan limit).

Args:
sql_query (str): Standard GoogleSQL query. Must reference tables using their full `gcp_id` from `get_dataset_details()`.
slug (str): A short filename-safe slug for this query's result, in the user's language, lowercase with underscores (e.g. "populacao_por_ano").

Rules:
- Use fully qualified names: `project.dataset.table`.
- Select only needed columns, don't use `SELECT *`.
- Always filter by partitioned columns when present (see `partitioned_by` in `get_table_details` results). In `JOIN` queries, each partitioned table needs its own partition filter.
- Order by relevant columns.
- Use `LIMIT` for exploration.
- Use appropriate data types in comparisons.
- Only `SELECT` statements are allowed.
sql_query (str): The query. Follow the SQL rules in the system prompt.
slug (str): Short, filename-safe, lowercase_with_underscores name for this result's
download, in the user's language. Must be distinct from the other queries in the
current request — each slug names a separate download.

Returns:
str: A JSON object with:
- `row_count`: the number of rows returned.
- `rows`: the rows as a JSON array.
If the query returns no rows, a short message string is returned instead.
JSON object with `row_count` and `rows`.
"""
client = _bq_client()

Expand Down Expand Up @@ -100,16 +82,6 @@ def execute_bigquery_sql(
) from e
raise

if not rows:
message = (
"Query returned 0 rows. Review the filters per the empty-result protocol."
)
return json.dumps(message, ensure_ascii=False), None

# Server-minted handle for the anonymous result table BigQuery already materialized
# (~24h TTL), so a later export hands back exactly these rows without re-running.
query_ref = f"qr_{uuid.uuid4().hex}"

payload = {"row_count": total_rows, "rows": rows}

# Surface truncation only when it actually happened, so the agent knows the rows
Expand All @@ -123,6 +95,14 @@ def execute_bigquery_sql(

content = json.dumps(payload, ensure_ascii=False, default=str)

# No rows -> nothing to download
if not rows:
return content, None

# Server-minted handle for the anonymous result table BigQuery already materialized
# (~24h TTL), so a later export hands back exactly these rows without re-running.
query_ref = f"qr_{uuid.uuid4().hex}"

artifact = {
"type": "query_result",
"query_ref": query_ref,
Expand All @@ -140,21 +120,15 @@ def decode_table_values(
runtime: ToolRuntime[AgentContext],
column_name: str | None = None,
) -> str:
"""Fetch the dictionary mapping (code -> human-readable value) for a coded column.

REQUIRED whenever a column has `needs_decoding: true` in `get_table_details`,
BEFORE writing any SQL that filters, joins, or displays that column.

Returns pairs of `chave` (the literal value stored in the table) and `valor` (its meaning).
"""Fetch the code->label dictionary for a coded (`needs_decoding`) column.

Args:
table_gcp_id (str): Full BigQuery table reference (`project.dataset.table`).
column_name (str | None, optional): The specific column to decode. Always
provide this when you know which column you need; passing None returns
the entire dictionary for the table and wastes tokens.
table_gcp_id (str): Full table reference (`project.dataset.table`).
column_name (str | None, optional): The column to decode. Omit only to fetch the
whole table's dictionary, which costs more tokens.

Returns:
str: JSON array of {nome_coluna, chave, valor} entries.
JSON array of {nome_coluna, chave (stored value), valor (meaning)}.
"""
if "`" in table_gcp_id:
table_gcp_id = table_gcp_id.replace("`", "")
Expand Down
17 changes: 0 additions & 17 deletions app/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,23 +49,6 @@ def localized_field(node: dict, field: str, language: LanguageCode) -> str | Non
return node.get(f"{field}{suffix}") or node.get(f"{field}Pt")


def language_directive(language: LanguageCode) -> str:
"""Build the instruction that sets the site's language as the response default.

Args:
language (LanguageCode): A supported language code.

Returns:
str: A one-line directive.
"""
name = _LANGUAGE_NAMES[language]

return (
f"The interface language is {name}. Respond in {name} unless the user clearly "
f"writes in another language, in which case respond in that language."
)


# ===========================================================================
# == Server-emitted user-facing text ==
# ===========================================================================
Expand Down
21 changes: 13 additions & 8 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
ModelCallLimitMiddleware,
SummarizationMiddleware,
)
from langchain.chat_models import init_chat_model
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from loguru import logger
from psycopg.rows import dict_row
Expand Down Expand Up @@ -53,18 +53,23 @@ async def lifespan(app: FastAPI): # pragma: no cover
"row_factory": dict_row,
}

model = init_chat_model(
model = ChatOpenAI(
api_key=settings.OPENAI_API_KEY,
model=settings.MODEL_URI,
temperature=settings.MODEL_TEMPERATURE,
credentials=settings.GOOGLE_CREDENTIALS,
thinking_level=settings.THINKING_LEVEL,
include_thoughts=True,
reasoning={
"effort": settings.REASONING_EFFORT,
"summary": "auto",
},
)

# Once the running context passes the trigger, summarize: older turns
# collapse into one summary while the most recent tokens are kept verbatim,
# and the summary is built from the full discarded history (no trimming).
summ_middleware = SummarizationMiddleware(
model=model,
trigger=("fraction", 0.5),
keep=("fraction", 0.25),
trigger=("tokens", 500_000),
keep=("tokens", 100_000),
trim_tokens_to_summarize=None,
)

limit_middleware = ModelCallLimitMiddleware(
Expand Down
16 changes: 5 additions & 11 deletions app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,21 +119,15 @@ def GOOGLE_CREDENTIALS(self) -> Credentials: # pragma: no cover
# ============================================================
# == LLM settings ==
# ============================================================
OPENAI_API_KEY: NonEmptyStr = Field(description="OpenAI API Key.")
MODEL_URI: NonEmptyStr = Field(
description=(
"Defines the LLM to be used. Refer to the LangChain docs for valid values: "
"https://reference.langchain.com/python/langchain/models/#langchain.chat_models.init_chat_model."
"Defines the OpenAI model to be used. Refer to the OpenAI api docs "
"for valid values: https://developers.openai.com/api/docs/models."
)
)
MODEL_TEMPERATURE: float = Field(
description=(
"Controls the randomness of the model’s output. "
"A higher number makes responses more creative; "
"lower ones make them more deterministic."
)
)
THINKING_LEVEL: Literal["minimum", "low", "medium", "high"] = Field(
description="Controls the amount of thinking Gemini models performs before returning a response."
REASONING_EFFORT: Literal["none", "low", "medium", "high", "xhigh", "max"] = Field(
description="Controls how much GPT-5.6 models think when performing a task."
)

# ============================================================
Expand Down
Loading