Reply to chat in the user's UI language (#7766)

# Description of Changes

Pass a user browser lang ID to engine

<img width="1400" height="900" alt="image"
src="https://github.com/user-attachments/assets/7e8fc5c2-8881-4a74-b718-7f5cd350d457"
/>



---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] Every comment I added says something the code does not
([guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/CODE_COMMENTS.md))
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-09-02 12:35:22 +00:00
committed by GitHub
parent 42bdce155c
commit 798ba57f0b
14 changed files with 127 additions and 8 deletions
@@ -25,4 +25,7 @@ public class AiWorkflowRequest {
"Prior chat messages exchanged between the user and the assistant, ordered"
+ " oldest-first. Excludes the current userMessage.")
private List<AiConversationMessage> conversationHistory = new ArrayList<>();
@Schema(description = "IETF language tag the reply should be written in", example = "fr-FR")
private String locale;
}
@@ -185,6 +185,7 @@ public class AiWorkflowService {
initialRequest.setConversationHistory(
new ArrayList<>(request.getConversationHistory()));
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
initialRequest.setLocale(request.getLocale());
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
WorkflowState state = new WorkflowState.Pending(initialRequest);
@@ -287,6 +288,7 @@ public class AiWorkflowService {
nextRequest.setArtifacts(pdfContentExtractor.buildArtifacts(contentResults));
nextRequest.setResumeWith(response.getResumeWith());
nextRequest.setEnabledEndpoints(request.getEnabledEndpoints());
nextRequest.setLocale(request.getLocale());
return new WorkflowState.Pending(nextRequest);
} finally {
for (LoadedFile lf : loadedFiles) {
@@ -338,6 +340,7 @@ public class AiWorkflowService {
nextRequest.setFiles(request.getFiles());
nextRequest.setConversationHistory(request.getConversationHistory());
nextRequest.setResumeWith(response.getResumeWith());
nextRequest.setLocale(request.getLocale());
return new WorkflowState.Pending(nextRequest);
}
@@ -530,6 +533,7 @@ public class AiWorkflowService {
new PdfContentExtractor.ToolReportArtifact(
result.reportTool(), result.report()));
resumeRequest.setResumeWith(resumeWith);
resumeRequest.setLocale(previousRequest.getLocale());
return new WorkflowState.Pending(resumeRequest);
}
@@ -802,5 +806,6 @@ public class AiWorkflowService {
private List<WorkflowArtifact> artifacts = new ArrayList<>();
private String resumeWith;
private List<String> enabledEndpoints = new ArrayList<>();
private String locale;
}
}
+4 -1
View File
@@ -30,7 +30,7 @@ from stirling.contracts import (
)
from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse
from stirling.models import ApiModel
from stirling.services import AppRuntime
from stirling.services import AppRuntime, language_directive, set_reply_locale
logger = logging.getLogger(__name__)
@@ -153,6 +153,8 @@ class OrchestratorAgent:
)
async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse:
# Bound once; delegates and worker tasks inherit it.
set_reply_locale(request.locale)
logger.info(
"[orchestrator] handle: files=%s resume_with=%s artifacts=%s msg=%r",
[file.name for file in request.files],
@@ -270,6 +272,7 @@ class OrchestratorAgent:
f"User message: {request.user_message}\n"
f"Files: {format_file_names(request.files)}\n"
f"Available artifacts:\n{artifact_summary}"
f"\n{language_directive()}"
)
def _describe_artifacts(self, request: OrchestratorRequest) -> str:
@@ -30,7 +30,7 @@ from stirling.contracts.pdf_comments import (
)
from stirling.logging import Pretty
from stirling.models import ApiModel
from stirling.services import AppRuntime
from stirling.services import AppRuntime, language_directive
logger = logging.getLogger(__name__)
@@ -160,6 +160,8 @@ class PdfCommentAgent:
]
for index, chunk in enumerate(request.chunks):
lines.append(f"[{index}] page={chunk.page + 1} text={json.dumps(chunk.text)}")
# Last, after the untrusted chunk text.
lines.append(f"\n{language_directive()}")
return "\n".join(lines)
@staticmethod
@@ -45,7 +45,7 @@ from stirling.contracts.pdf_create import (
WrittenSections,
)
from stirling.models.agent_tool_models import AgentToolId, CreatePdfFromHtmlAgentParams
from stirling.services import AppRuntime
from stirling.services import AppRuntime, language_directive
logger = logging.getLogger(__name__)
@@ -260,6 +260,7 @@ def _build_sections_prompt(meta: DocumentMeta, user_request: str, history: str)
lines.append(f"\nConversation history:\n{history}")
lines.append(f"\nUser request: {user_request}")
lines.append(f"\n{language_directive()}")
return "\n".join(lines)
@@ -292,6 +293,7 @@ def _build_writer_prompt(plan: DocumentPlan, chunk: _Chunk) -> str:
for point in s.key_points:
lines.append(f" - {point}")
lines.append(f"\n{language_directive()}")
return "\n".join(lines)
@@ -338,6 +340,7 @@ class PdfCreateAgent:
# ── Phase 1: plan meta ─────────────────────────────────────────────────
logger.info("[pdf-create] phase 1/6: planning document meta")
meta_prompt = f"Conversation history:\n{history}\n\nUser request: {request.user_message}"
meta_prompt += f"\n\n{language_directive()}"
meta_result = await self._meta_planner.run(meta_prompt)
meta = meta_result.output
+2 -1
View File
@@ -27,7 +27,7 @@ from stirling.contracts import (
)
from stirling.logging import Pretty
from stirling.models import OPERATIONS, ApiModel, ParamToolModel, ToolEndpoint
from stirling.services import AppRuntime, ToolChainStep, blocking, validate_tool_chain
from stirling.services import AppRuntime, ToolChainStep, blocking, language_directive, validate_tool_chain
logger = logging.getLogger(__name__)
@@ -357,6 +357,7 @@ class PdfEditAgent:
f"{unavailable_line}"
f"{repair_line}"
f"Extracted page text:\n{format_page_text(request.page_text)}"
f"\n{language_directive()}"
)
# Endpoints that exist on the server and are callable via the direct API or the manual UI,
+3 -1
View File
@@ -29,7 +29,7 @@ from stirling.contracts import (
from stirling.documents import RagCapability
from stirling.models import PrincipalId
from stirling.models.agent_tool_models import AgentToolId, MathAuditorAgentParams
from stirling.services import AppRuntime, require_current_user_id
from stirling.services import AppRuntime, language_directive, require_current_user_id
logger = logging.getLogger(__name__)
@@ -223,6 +223,7 @@ class PdfQuestionAgent:
forbids invented figures; the LLM only restates Verdict facts.
"""
prompt = f"User question:\n{user_message}\n\nMath audit Verdict (JSON):\n{verdict.model_dump_json()}"
prompt += f"\n\n{language_directive()}"
result = await self._math_synth_agent.run(prompt)
return result.output
@@ -233,4 +234,5 @@ class PdfQuestionAgent:
f"Files: {format_file_names(request.files)}\n"
f"Question: {request.question}\n"
"Pick the right retrieval tool for this question, then answer from what it returns."
f"\n{language_directive()}"
)
+3 -1
View File
@@ -56,7 +56,7 @@ from stirling.models.agent_tool_models import (
PdfCommentAgentParams,
)
from stirling.models.tool_models import AddCommentsParams
from stirling.services import AppRuntime, require_current_user_id
from stirling.services import AppRuntime, language_directive, require_current_user_id
# Fallback right-margin placement used when a finding has no usable
# anchor text. A4/Letter portrait assumed.
@@ -209,6 +209,7 @@ class PdfReviewAgent:
placement geometry to produce the JSON the ``add-comments`` tool wants.
"""
prompt = f"User review request:\n{user_message}\n\nMath audit Verdict (JSON):\n{verdict.model_dump_json()}"
prompt += f"\n\n{language_directive()}"
result = await self._localiser_agent.run(prompt)
specs = self._build_comment_specs(verdict, result.output.comments)
serialised = [spec.model_dump(by_alias=True, exclude_none=True) for spec in specs]
@@ -238,6 +239,7 @@ class PdfReviewAgent:
prompt = (
f"<user_message>{_escape_for_tag(user_message)}</user_message>\n"
f"<verdict>{_escape_for_tag(report.model_dump_json())}</verdict>"
f"\n{language_directive()}"
)
result = await self._contradiction_localiser.run(prompt)
specs = self._build_paired_comment_specs(report, result.output.comments)
+3 -1
View File
@@ -21,7 +21,7 @@ from stirling.contracts import (
format_conversation_history,
)
from stirling.models import ApiModel
from stirling.services import AppRuntime
from stirling.services import AppRuntime, language_directive
class UserSpecMetadata(ApiModel):
@@ -98,6 +98,7 @@ class UserSpecAgent:
f"Edit plan summary:\n{edit_plan.summary}\n\n"
f"Edit plan rationale:\n{edit_plan.rationale or 'None'}\n\n"
f"Edit plan steps:\n{edit_plan.model_dump_json(indent=2)}"
f"\n\n{language_directive()}"
)
def _build_revision_prompt(self, request: AgentRevisionRequest, edit_plan: EditPlanResponse) -> str:
@@ -108,6 +109,7 @@ class UserSpecAgent:
f"Edit plan summary:\n{edit_plan.summary}\n\n"
f"Edit plan rationale:\n{edit_plan.rationale or 'None'}\n\n"
f"Edit plan steps:\n{edit_plan.model_dump_json(indent=2)}"
f"\n\n{language_directive()}"
)
async def _build_edit_plan(
@@ -42,6 +42,8 @@ class OrchestratorRequest(ApiModel):
conversation_history: list[ConversationMessage] = Field(default_factory=list)
artifacts: list[WorkflowArtifact] = Field(default_factory=list)
resume_with: SupportedCapability | None = None
# Reply language (IETF tag); unset falls back to the message's own language.
locale: str | None = None
# See `PdfEditRequest.enabled_endpoints`.
enabled_endpoints: Annotated[list[ToolEndpoint], BeforeValidator(drop_unknown_tool_endpoints)] = Field(
default_factory=list
+3
View File
@@ -1,5 +1,6 @@
"""Shared services used by the Stirling AI runtime."""
from .language import language_directive, set_reply_locale
from .progress import (
ProgressEmitter,
emit_progress,
@@ -20,9 +21,11 @@ __all__ = [
"build_runtime",
"current_user_id",
"emit_progress",
"language_directive",
"require_current_user_id",
"reset_progress_emitter",
"set_progress_emitter",
"set_reply_locale",
"setup_posthog_tracking",
"validate_tool_chain",
]
+23
View File
@@ -0,0 +1,23 @@
"""Per-request reply language, bound by the orchestrator, read by prompt builders."""
from __future__ import annotations
from contextvars import ContextVar
_locale: ContextVar[str | None] = ContextVar("stirling_reply_locale", default=None)
def set_reply_locale(locale: str | None) -> None:
_locale.set(locale)
def language_directive() -> str:
"""Prompt line pinning the reply language; append to any user-facing prompt."""
locale = _locale.get()
if not locale:
return "Write anything the user will read in the same language as their message."
return (
f"Write anything the user will read in the language of locale '{locale}', whatever "
"language this prompt, the documents, or the tool output are in. Only a different "
"language the user explicitly asks for overrides this."
)
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from collections.abc import Iterator
from typing import Any, cast
import pytest
from stirling.agents import OrchestratorAgent
from stirling.agents.pdf_questions import PdfQuestionAgent
from stirling.contracts import (
OrchestratorRequest,
PdfQuestionAnswerResponse,
PdfQuestionRequest,
SupportedCapability,
)
from stirling.services import language_directive, set_reply_locale
from stirling.services.runtime import AppRuntime
@pytest.fixture(autouse=True)
def reset_locale() -> Iterator[None]:
set_reply_locale(None)
yield
set_reply_locale(None)
def test_directive_falls_back_to_the_message_language() -> None:
assert "same language as their message" in language_directive()
def test_directive_pins_the_bound_locale() -> None:
set_reply_locale("fr-FR")
assert "'fr-FR'" in language_directive()
def test_orchestrator_request_carries_the_locale() -> None:
assert OrchestratorRequest.model_validate({"userMessage": "hi", "locale": "de-DE"}).locale == "de-DE"
assert OrchestratorRequest.model_validate({"userMessage": "hi"}).locale is None
def test_question_prompt_carries_the_directive() -> None:
set_reply_locale("es-ES")
# _build_prompt ignores self, so call it off the class.
prompt = PdfQuestionAgent._build_prompt(cast(Any, None), PdfQuestionRequest(question="¿Cuántas páginas?"))
assert "'es-ES'" in prompt
@pytest.mark.anyio
async def test_handle_binds_the_locale_for_delegates(runtime: AppRuntime, monkeypatch: pytest.MonkeyPatch) -> None:
"""The resume path reaches a delegate with the request's locale already bound."""
agent = OrchestratorAgent(runtime)
seen: list[str] = []
async def capture(request: OrchestratorRequest) -> PdfQuestionAnswerResponse:
seen.append(language_directive())
return PdfQuestionAnswerResponse(answer="ok")
monkeypatch.setattr(agent, "_run_pdf_question", capture)
await agent.handle(
OrchestratorRequest(
user_message="Combien de pages ?",
locale="fr-FR",
resume_with=SupportedCapability.PDF_QUESTION,
)
)
assert "'fr-FR'" in seen[0]
@@ -402,7 +402,7 @@ const initialState: ChatState = {
};
export function ChatProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [state, dispatch] = useReducer(chatReducer, initialState);
const { files: activeFiles, fileStubs: activeFileStubs } = useAllFiles();
const { actions: fileActions } = useFileActions();
@@ -552,6 +552,8 @@ export function ChatProvider({ children }: { children: ReactNode }) {
try {
const formData = new FormData();
formData.append("userMessage", content);
// The engine replies in this language instead of guessing.
if (i18n.language) formData.append("locale", i18n.language);
sourceFiles.forEach((file, i) => {
formData.append(`fileInputs[${i}].fileInput`, file);
});