Skip to content

Commit 5ed8769

Browse files
Merge pull request #56 from basedosdados/staging
chore: sync main with staging
2 parents 836fc3a + f52d656 commit 5ed8769

21 files changed

Lines changed: 3712 additions & 190 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@ wheels/
2929
.pytest_cache/
3030
htmlcov/
3131
.coverage
32+
33+
# Eval artifacts (generated transcripts + scores; regenerable, can be large)
34+
eval/*.json
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Add structured_response column to messages table.
2+
3+
Revision ID: 21d5a7602704
4+
Revises: 19e2c92563e2
5+
Create Date: 2026-06-22 13:20:35.583925
6+
"""
7+
8+
from typing import Sequence, Union
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
# revision identifiers, used by Alembic.
14+
revision: str = "21d5a7602704"
15+
down_revision: Union[str, Sequence[str], None] = "19e2c92563e2"
16+
branch_labels: Union[str, Sequence[str], None] = None
17+
depends_on: Union[str, Sequence[str], None] = None
18+
19+
20+
def upgrade() -> None:
21+
"""Upgrade schema."""
22+
op.add_column(
23+
"message",
24+
sa.Column("structured_response", sa.JSON(none_as_null=True), nullable=True),
25+
)
26+
27+
28+
def downgrade() -> None:
29+
"""Downgrade schema."""
30+
op.drop_column("message", "structured_response")

app/agent/prompts.py

Lines changed: 97 additions & 92 deletions
Large diffs are not rendered by default.

app/agent/schemas.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from enum import Enum
2+
3+
from pydantic import BaseModel, Field
4+
5+
6+
class TemporalGranularity(str, Enum):
7+
"""Granularity of the data's temporal coverage."""
8+
9+
DAY = "day"
10+
MONTH = "month"
11+
YEAR = "year"
12+
13+
14+
class TemporalCoverage(BaseModel):
15+
"""The interval the SQL query actually filtered on in the answer."""
16+
17+
period_start: str = Field(
18+
description=(
19+
"Start of the interval filtered by the SQL query (e.g. '2010' for "
20+
"`ano = 2010` or `ano BETWEEN 2010 AND 2012`). Format it to match `granularity`: "
21+
"YYYY (year), YYYY-MM (month) or YYYY-MM-DD (day) — e.g. '2010', '2010-01', '2010-01-01'. "
22+
"May be narrower than the table's full coverage."
23+
)
24+
)
25+
period_end: str = Field(
26+
description=(
27+
"End of the interval filtered by the SQL query (e.g. '2010' for "
28+
"`ano = 2010`; '2012' for `ano BETWEEN 2010 AND 2012`). Format it to match `granularity`: "
29+
"YYYY (year), YYYY-MM (month) or YYYY-MM-DD (day) — e.g. '2012', '2012-01', '2012-01-01'. "
30+
"May be narrower than the table's full coverage."
31+
)
32+
)
33+
granularity: TemporalGranularity = Field(
34+
description=(
35+
"Granularity of `period_start`/`period_end`, matching their format: "
36+
"YYYY (year), YYYY-MM (month), YYYY-MM-DD (day)."
37+
)
38+
)
39+
40+
41+
class DataSource(BaseModel):
42+
"""A Base dos Dados table the answer draws on or points the user to."""
43+
44+
dataset_id: str = Field(
45+
description=(
46+
"Dataset UUID (the `dataset_id` field from `get_table_details` or the `id` "
47+
"field from `get_dataset_details`), not the BigQuery id (e.g. 'br_bd_diretorios')."
48+
)
49+
)
50+
table_id: str = Field(
51+
description=(
52+
"Table UUID (the `id` field from `get_table_details`, or a table's `id` from "
53+
"the tables list of `get_dataset_details`). Not the dataset UUID, not the BigQuery id."
54+
)
55+
)
56+
name: str = Field(description="Human-readable name of the table.")
57+
58+
59+
class StructuredResponse(BaseModel):
60+
"""The agent's structured response for the user interface."""
61+
62+
response: str = Field(
63+
description=(
64+
"The prose answer in Markdown, written in the user's language: a direct answer to the question "
65+
"with the data obtained, plus analysis and context. Do NOT repeat the source, period, SQL, "
66+
"or suggestions here — each of those has its own dedicated field."
67+
)
68+
)
69+
data_sources: list[DataSource] | None = Field(
70+
default=None,
71+
description=(
72+
"The tables the answer draws on — those you queried, or specific tables you recommend "
73+
"on clarification turns. Leave empty (None) when no table is relevant."
74+
),
75+
)
76+
temporal_coverage: TemporalCoverage | None = Field(
77+
default=None,
78+
description=(
79+
"The interval your SQL query actually filtered. Leave empty (None) when no query "
80+
"was run (e.g. a clarification turn) or the answer has no temporal dimension."
81+
),
82+
)
83+
sql_queries: list[str] | None = Field(
84+
default=None,
85+
description=(
86+
"The SQL queries whose results the answer is based on, each with "
87+
"inline comments, so the user can reproduce the result. Include every "
88+
"query that contributed to the answer (e.g. one query per metric when the "
89+
"answer combines several), but EXCLUDE exploratory or failed-then-corrected queries. "
90+
"Leave empty (None) when no query was executed."
91+
),
92+
)
93+
follow_up_questions: list[str] | None = Field(
94+
default=None,
95+
description=(
96+
"3 suggested follow-up questions (in the user's language) to explore the data further."
97+
),
98+
)

app/agent/tools/api.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,19 @@ async def search_datasets(query: str) -> str:
4646
CRITICAL: Use individual KEYWORDS only, not full sentences. The search engine uses Elasticsearch.
4747
4848
Args:
49-
query (str): 2-3 keywords maximum. Use Portuguese terms, organization acronyms, or dataset acronyms.
50-
Good Examples: "censo", "educacao", "ibge", "inep", "rais", "saude".
49+
query (str): 2-3 keywords maximum. Use Portuguese terms, organization names, or dataset names.
50+
Good Examples: "censo", "rais", "ibge", "inep", "educacao", "saude".
5151
Avoid: "Brazilian population data by municipality".
5252
5353
Returns:
5454
str: JSON array of datasets. If empty/irrelevant results, try different keywords.
5555
56-
Strategy: Start with broad terms like "censo", "ibge", "inep", "rais", then get specific if needed.
56+
Strategy: hierarchical funnel — ALWAYS start with a SINGLE keyword and broaden a level only if it returns nothing:
57+
1. Dataset name ("censo", "rais", "enem") or organization ("ibge", "inep", "tse").
58+
2. Core theme ("educacao", "saude", "economia", "emprego").
59+
3. English term ("health", "education").
60+
4. A 2-3 word combination only if the levels above fail ("saude ms", "censo municipio").
61+
5762
Next step: Use `get_dataset_details()` with returned dataset IDs.
5863
"""
5964
response = await _client.get(
@@ -91,7 +96,7 @@ async def get_dataset_details(dataset_id: str) -> str:
9196
9297
Args:
9398
dataset_id (str): Dataset ID obtained from `search_datasets()`.
94-
This is typically a UUID-like string, not the human-readable name.
99+
This is a UUID-like string, not the human-readable name.
95100
96101
Returns:
97102
str: JSON object with complete dataset information, including:
@@ -173,6 +178,7 @@ async def get_dataset_details(dataset_id: str) -> str:
173178
dataset_tables.append(
174179
TableOverview(
175180
id=table_id,
181+
dataset_id=dataset_id,
176182
gcp_id=table_gcp_id,
177183
name=table_name,
178184
description=table_description,
@@ -225,8 +231,6 @@ async def get_table_details(table_id: str) -> str:
225231
- period_start / period_end: First and last period covered by the table.
226232
Format varies (`2024`, `'2026-04-12'`, etc.) — use the value verbatim,
227233
matched to the appropriate temporal column (`ano`, `data`, etc.).
228-
229-
Next step: Use `execute_bigquery_sql()` to execute queries.
230234
"""
231235
response = await _client.post(
232236
url=GRAPHQL_URL,
@@ -296,8 +300,11 @@ async def get_table_details(table_id: str) -> str:
296300
)
297301
)
298302

303+
dataset_id = table["dataset"]["id"].split("DatasetNode:")[-1]
304+
299305
result = Table(
300306
id=table_id,
307+
dataset_id=dataset_id,
301308
gcp_id=table_gcp_id,
302309
name=table_name,
303310
description=table_description,

app/agent/tools/bigquery.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ def _get_client() -> bq.Client: # pragma: no cover
2626
def execute_bigquery_sql(sql_query: str, config: RunnableConfig) -> str:
2727
"""Execute a SQL query against BigQuery tables from the Base dos Dados database.
2828
29+
PRECONDITION — only call this when the question is already specific enough to
30+
answer with data. For a broad/exploratory question (a bare topic) or one that
31+
references an entity the user did not name, do NOT call this tool: explore the
32+
metadata and ask the user to refine the question first.
33+
2934
Use AFTER identifying the right datasets and understanding tables structure.
3035
It includes a 10GB processing limit for safety.
3136

app/agent/tools/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class TableOverview(BaseModel):
1616
"""Basic table information without column details."""
1717

1818
id: str
19+
dataset_id: str
1920
gcp_id: str | None
2021
name: str
2122
description: str | None

app/agent/tools/queries.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@
9898
}
9999
}
100100
}
101+
dataset {
102+
id
103+
}
101104
}
102105
}
103106
}

app/api/streaming/agent_runner.py

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
from langgraph.graph.state import CompiledStateGraph
77
from loguru import logger
88

9+
from app.agent.schemas import StructuredResponse
910
from app.api.schemas import ConfigDict
11+
from app.api.streaming.data_sources import resolve_data_source_names
1012
from app.api.streaming.schemas import EventData, StreamEvent, ToolCall, ToolOutput
1113
from app.api.streaming.security import sanitize_markdown_links
1214
from app.db.database import AsyncDatabase, sessionmaker
@@ -84,35 +86,6 @@ def _truncate_json(
8486
return json.dumps(data, ensure_ascii=False, indent=2)
8587

8688

87-
def _parse_thinking(message: AIMessage) -> str | None:
88-
"""Parse thinking content from an AI message.
89-
90-
Some models (e.g., Gemini 3) return `message.content` as a list of typed blocks,
91-
which may include `{"type": "thinking", "thinking": "..."}` entries. When
92-
`content` is a plain string, no thinking is available.
93-
94-
Args:
95-
message (AIMessage): The AI message from where to parse the thinking.
96-
97-
Returns:
98-
str | None: The concatenated thinking text, or None if no thinking blocks exist.
99-
"""
100-
if isinstance(message.content, str):
101-
return None
102-
103-
blocks = [
104-
block
105-
for block in message.content
106-
if isinstance(block, dict)
107-
and block.get("type") == "thinking"
108-
and isinstance(block.get("thinking"), str)
109-
]
110-
111-
thinking = "".join(block["thinking"] for block in blocks)
112-
113-
return thinking or None
114-
115-
11689
def _process_chunk(chunk: dict[str, Any]) -> StreamEvent | None:
11790
"""Process a streaming chunk from a react agent workflow into a standardized StreamEvent.
11891
@@ -128,7 +101,27 @@ def _process_chunk(chunk: dict[str, Any]) -> StreamEvent | None:
128101
- None for ignored chunks
129102
"""
130103
if "model" in chunk:
131-
ai_messages: list[AIMessage] = chunk["model"]["messages"]
104+
update: dict[str, Any] = chunk["model"]
105+
106+
# When `response_format` is set (see app.main:91), the model node sets `structured_response`
107+
# (a StructuredResponse) on the turn it produces the final answer. This is the final answer;
108+
# the accompanying structured-output tool call / ToolMessage in `update["messages"]` is
109+
# internal and must not be emitted as a tool_call.
110+
structured: StructuredResponse | None = update.get("structured_response")
111+
112+
if structured is not None:
113+
response_text = sanitize_markdown_links(structured.response)
114+
structured_response = structured.model_dump()
115+
structured_response["response"] = response_text
116+
return StreamEvent(
117+
type="final_answer",
118+
data=EventData(
119+
content=response_text,
120+
structured_response=structured_response,
121+
),
122+
)
123+
124+
ai_messages: list[AIMessage] = update["messages"]
132125

133126
# If no messages are returned, the model returned an empty response
134127
# with no tool calls. This also counts as a final (but empty) answer.
@@ -145,7 +138,7 @@ def _process_chunk(chunk: dict[str, Any]) -> StreamEvent | None:
145138
)
146139
for tool_call in message.tool_calls
147140
]
148-
content = _parse_thinking(message) or message.text
141+
content = message.text
149142
else:
150143
event_type = "final_answer"
151144
tool_calls = None
@@ -183,15 +176,17 @@ def _process_chunk(chunk: dict[str, Any]) -> StreamEvent | None:
183176
]
184177

185178
return StreamEvent(
186-
type="tool_output", data=EventData(tool_outputs=tool_outputs)
179+
type="tool_output",
180+
data=EventData(tool_outputs=tool_outputs),
187181
)
188182
elif "ModelCallLimitMiddleware.before_model" in chunk:
189183
# before_model runs on every model iteration; only the limit-exceeded
190184
# path sets jump_to="end", so check that rather than the key's presence.
191185
update = chunk["ModelCallLimitMiddleware.before_model"] or {}
192186
if update.get("jump_to") == "end":
193187
event_data = EventData(
194-
content=ErrorMessage.MODEL_CALL_LIMIT_REACHED, tool_calls=None
188+
content=ErrorMessage.MODEL_CALL_LIMIT_REACHED,
189+
tool_calls=None,
195190
)
196191
return StreamEvent(type="model_call_limit", data=event_data)
197192
return None
@@ -223,6 +218,7 @@ async def run_agent(
223218
events = []
224219
artifacts = []
225220
assistant_message = ""
221+
structured_response: dict[str, Any] | None = None
226222
status: MessageStatus | None = None
227223

228224
try:
@@ -245,6 +241,9 @@ async def run_agent(
245241
artifacts.append(output.artifact)
246242
elif event.type == "final_answer":
247243
assistant_message = event.data.content
244+
if event.data.structured_response is not None:
245+
await resolve_data_source_names(event.data.structured_response)
246+
structured_response = event.data.structured_response
248247
status = MessageStatus.SUCCESS
249248
elif event.type == "model_call_limit":
250249
assistant_message = event.data.content
@@ -280,6 +279,7 @@ async def run_agent(
280279
content=assistant_message,
281280
artifacts=artifacts or None,
282281
events=events or None,
282+
structured_response=structured_response,
283283
status=status or MessageStatus.ERROR,
284284
)
285285
try:

0 commit comments

Comments
 (0)