Skip to content

Commit 2114cf5

Browse files
authored
Merge pull request #58 from basedosdados/feat/language-support
feat: localize the chatbot by language (pt/en/es)
2 parents 678c1ef + cc8d8a5 commit 2114cf5

8 files changed

Lines changed: 276 additions & 49 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Add language column to threads table.
2+
3+
Revision ID: 4b3d2fa4a75f
4+
Revises: f6ce7837e023
5+
Create Date: 2026-08-04 19:50:02.000000
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 = "4b3d2fa4a75f"
15+
down_revision: Union[str, Sequence[str], None] = "f6ce7837e023"
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+
# server_default backfills existing threads with the Portuguese default; new rows get
23+
# their value from the application (ThreadPayload.language).
24+
op.add_column(
25+
"thread",
26+
sa.Column("language", sa.String(), nullable=False, server_default="pt"),
27+
)
28+
29+
30+
def downgrade() -> None:
31+
"""Downgrade schema."""
32+
op.drop_column("thread", "language")

app/api/routers/chatbot.py

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
ResultTooLarge,
3131
materialize_export,
3232
)
33+
from app.i18n import DEFAULT_LANGUAGE, t
3334
from app.settings import settings
3435
from app.storage import generate_signed_url
3536

@@ -118,6 +119,7 @@ async def create_thread(
118119
thread_create = ThreadCreate(
119120
title=thread_payload.title,
120121
user_id=user_id,
122+
language=thread_payload.language,
121123
)
122124

123125
return await database.create_thread(thread_create)
@@ -169,13 +171,17 @@ async def send_message(
169171
running_runs: RunningRuns,
170172
user_id: UserID,
171173
) -> StreamingResponse:
172-
await _authorize_thread(database, thread_id, user_id)
174+
thread = await _authorize_thread(database, thread_id, user_id)
173175

174176
run_id = str(uuid.uuid4())
175177

176178
config = ConfigDict(
177179
run_id=run_id,
178-
configurable={"thread_id": thread_id, "user_id": user_id},
180+
configurable={
181+
"thread_id": thread_id,
182+
"user_id": user_id,
183+
"language": thread.language,
184+
},
179185
)
180186

181187
message_create = MessageCreate(
@@ -196,6 +202,7 @@ async def send_message(
196202
thread_id=thread_id,
197203
user_message=message,
198204
model_uri=settings.MODEL_URI,
205+
language=thread.language,
199206
queue=queue,
200207
),
201208
name=f"run_agent:{run_id}",
@@ -220,28 +227,18 @@ def _cleanup(task: asyncio.Task): # pragma: no cover
220227
)
221228

222229

223-
# User-facing details the frontend surfaces to the end user when a download fails.
224-
RESULTS_EXPIRED_DETAIL = "Estes resultados não estão mais disponíveis para download."
225-
226-
RESULTS_TOO_LARGE_DETAIL = (
227-
"Estes resultados são grandes demais para baixar em um único arquivo."
228-
)
229-
230-
# Fallback base name when a query's slug yields nothing filesystem-safe.
231-
DEFAULT_EXPORT_FILENAME = "resultados"
232-
233-
234-
def _sanitize_filename(slug: str) -> str:
230+
def _sanitize_filename(slug: str, fallback: str) -> str:
235231
"""Sanitize a query's slug into a safe base filename.
236232
237233
Args:
238234
slug (str): The query's slug.
235+
fallback (str): Base name to use when the slug yields nothing filesystem-safe.
239236
240237
Returns:
241238
str: A filesystem-safe base filename, without extension.
242239
"""
243240
filename = re.sub(r"[^\w-]+", "_", slug).strip("_")
244-
return filename or DEFAULT_EXPORT_FILENAME
241+
return filename or fallback
245242

246243

247244
@router.post("/messages/{message_id}/exports")
@@ -263,6 +260,11 @@ async def export_message_results(
263260

264261
message = await _authorize_message(database, message_id, user_id)
265262

263+
# The thread's language localizes the download-failure details below. _authorize_message
264+
# already validated the thread exists and is owned; re-read it to read its language.
265+
thread = await database.get_thread(message.thread_id)
266+
language = thread.language if thread else DEFAULT_LANGUAGE
267+
266268
query_handle = await database.get_query_handle(message.id, query_ref)
267269

268270
if query_handle is None:
@@ -277,18 +279,20 @@ async def export_message_results(
277279
query_ref=query_handle.query_ref,
278280
destination_table=query_handle.destination_table,
279281
file_format=file_format,
280-
filename=_sanitize_filename(query_handle.slug),
282+
filename=_sanitize_filename(
283+
query_handle.slug, t("default_export_filename", language)
284+
),
281285
message_id=str(message.id),
282286
)
283287
except ResultTableExpired as e:
284288
raise HTTPException(
285289
status_code=status.HTTP_410_GONE,
286-
detail=RESULTS_EXPIRED_DETAIL,
290+
detail=t("results_expired", language),
287291
) from e
288292
except ResultTooLarge as e:
289293
raise HTTPException(
290294
status_code=status.HTTP_400_BAD_REQUEST,
291-
detail=RESULTS_TOO_LARGE_DETAIL,
295+
detail=t("results_too_large", language),
292296
) from e
293297

294298
signed_url = generate_signed_url(

app/api/streaming/agent_runner.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,7 @@
2020
QueryHandle,
2121
)
2222
from app.exports import CollectedQueryHandle, collect_query_handles
23-
24-
25-
class ErrorMessage:
26-
INTERRUPTED = (
27-
"A conexão com o servidor foi interrompida. Por favor, tente novamente."
28-
)
29-
30-
MODEL_CALL_LIMIT_REACHED = (
31-
"Essa pergunta gerou um raciocínio muito longo e não consegui chegar a uma conclusão. "
32-
"Por favor, tente ser mais específico ou divida sua pergunta em partes menores."
33-
)
34-
35-
UNEXPECTED = "Ocorreu um erro inesperado. Por favor, tente novamente. Se o problema persistir, avise-nos."
23+
from app.i18n import DEFAULT_LANGUAGE, language_directive, t
3624

3725

3826
def _truncate_json(
@@ -93,11 +81,14 @@ def _truncate_json(
9381
return json.dumps(data, ensure_ascii=False, indent=2)
9482

9583

96-
def _process_chunk(chunk: dict[str, Any]) -> StreamEvent | None:
84+
def _process_chunk(
85+
chunk: dict[str, Any], language: str = DEFAULT_LANGUAGE
86+
) -> StreamEvent | None:
9787
"""Process a streaming chunk from a react agent workflow into a StreamEvent.
9888
9989
Args:
10090
chunk (dict[str, Any]): A raw update chunk from the agent workflow.
91+
language (str): The thread's language, for localizing server-emitted content.
10192
10293
Returns:
10394
StreamEvent | None: Structured event or None if the chunk is ignored:
@@ -192,7 +183,7 @@ def _process_chunk(chunk: dict[str, Any]) -> StreamEvent | None:
192183
update = chunk["ModelCallLimitMiddleware.before_model"] or {}
193184
if update.get("jump_to") == "end":
194185
event_data = EventData(
195-
content=ErrorMessage.MODEL_CALL_LIMIT_REACHED,
186+
content=t("error_model_call_limit", language),
196187
tool_calls=None,
197188
)
198189
return StreamEvent(type="model_call_limit", data=event_data)
@@ -231,6 +222,7 @@ async def run_agent(
231222
user_message: Message,
232223
model_uri: str,
233224
queue: asyncio.Queue[StreamEvent],
225+
language: str = DEFAULT_LANGUAGE,
234226
):
235227
"""Run the agent to completion and push events onto the queue.
236228
@@ -245,6 +237,8 @@ async def run_agent(
245237
thread_id (str): Thread unique identifier.
246238
user_message (Message): User message.
247239
model_uri (str): Model URI.
240+
language (str): The thread's language; sets the response-language default and
241+
localizes server-emitted error messages.
248242
queue (asyncio.Queue[StreamEvent]): Events queue.
249243
"""
250244
events = []
@@ -253,16 +247,22 @@ async def run_agent(
253247
collected_handles: list[CollectedQueryHandle] = []
254248
status: MessageStatus | None = None
255249

250+
# Prepend the language directive to the model input only — the persisted user Message
251+
# (created by the router) keeps the user's clean text. This sets the site's language as
252+
# the default while letting the model honor a user who writes in another language.
253+
# A dynamic-prompt middleware would keep it out of checkpoint history entirely; see PR notes.
254+
model_input = f"{language_directive(language)}\n\n{user_message.content}"
255+
256256
try:
257257
async for mode, chunk in agent.astream( # pragma: no cover
258-
input={"messages": [{"role": "user", "content": user_message.content}]},
258+
input={"messages": [{"role": "user", "content": model_input}]},
259259
config=config,
260260
stream_mode=["updates", "values"],
261261
):
262262
if mode == "values":
263263
continue
264264

265-
event = _process_chunk(chunk)
265+
event = _process_chunk(chunk, language)
266266

267267
if event is None:
268268
continue
@@ -290,12 +290,12 @@ async def run_agent(
290290
await queue.put(event)
291291
except asyncio.CancelledError:
292292
if status is None:
293-
assistant_message = ErrorMessage.INTERRUPTED
293+
assistant_message = t("error_interrupted", language)
294294
status = MessageStatus.INTERRUPTED
295295
raise
296296
except Exception:
297297
logger.exception(f"Unexpected error in run {config['run_id']}:")
298-
assistant_message = ErrorMessage.UNEXPECTED
298+
assistant_message = t("error_unexpected", language)
299299
status = MessageStatus.ERROR
300300
event = StreamEvent(
301301
type="error",

app/db/models.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,26 @@
33
from enum import Enum
44
from typing import Any
55

6-
from pydantic import JsonValue, computed_field
6+
from pydantic import JsonValue, computed_field, field_validator
77
from sqlalchemy import Enum as SAEnum
88
from sqlmodel import JSON, TIMESTAMP, Column, Field, Integer, Relationship, SQLModel
99

10+
from app.i18n import DEFAULT_LANGUAGE, normalize_language
11+
1012

1113
# =============================================================================
1214
# == Thread Models ==
1315
# =============================================================================
1416
class ThreadPayload(SQLModel):
1517
title: str
18+
# Captured at creation from the site's locale (pt/en/es). Steers the assistant's response
19+
# language and localizes server-emitted messages. Stored as a plain code; see app.i18n.
20+
language: str = Field(default=DEFAULT_LANGUAGE)
21+
22+
@field_validator("language")
23+
@classmethod
24+
def _normalize_language(cls, value: str) -> str:
25+
return normalize_language(value)
1626

1727

1828
class ThreadCreate(ThreadPayload):

0 commit comments

Comments
 (0)