From 798ba57f0b73a6c4a073181a8de904de17a7b2fa Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:35:22 +0000 Subject: [PATCH] Reply to chat in the user's UI language (#7766) # Description of Changes Pass a user browser lang ID to engine image --- ## 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. --- .../model/api/ai/AiWorkflowRequest.java | 3 + .../service/AiWorkflowService.java | 5 ++ engine/src/stirling/agents/orchestrator.py | 5 +- .../src/stirling/agents/pdf_comment/agent.py | 4 +- .../src/stirling/agents/pdf_create/agent.py | 5 +- engine/src/stirling/agents/pdf_edit.py | 3 +- engine/src/stirling/agents/pdf_questions.py | 4 +- engine/src/stirling/agents/pdf_review.py | 4 +- engine/src/stirling/agents/user_spec.py | 4 +- engine/src/stirling/contracts/orchestrator.py | 2 + engine/src/stirling/services/__init__.py | 3 + engine/src/stirling/services/language.py | 23 +++++++ engine/tests/test_reply_language.py | 66 +++++++++++++++++++ .../components/chat/ChatContext.tsx | 4 +- 14 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 engine/src/stirling/services/language.py create mode 100644 engine/tests/test_reply_language.py diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java index ad4c994c05..f990c4eb57 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java @@ -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 conversationHistory = new ArrayList<>(); + + @Schema(description = "IETF language tag the reply should be written in", example = "fr-FR") + private String locale; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java index b9e3488324..80cd0cff7f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java @@ -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 artifacts = new ArrayList<>(); private String resumeWith; private List enabledEndpoints = new ArrayList<>(); + private String locale; } } diff --git a/engine/src/stirling/agents/orchestrator.py b/engine/src/stirling/agents/orchestrator.py index bb7f2682df..0ef4345e06 100644 --- a/engine/src/stirling/agents/orchestrator.py +++ b/engine/src/stirling/agents/orchestrator.py @@ -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: diff --git a/engine/src/stirling/agents/pdf_comment/agent.py b/engine/src/stirling/agents/pdf_comment/agent.py index 9b62629a47..6a01c88103 100644 --- a/engine/src/stirling/agents/pdf_comment/agent.py +++ b/engine/src/stirling/agents/pdf_comment/agent.py @@ -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 diff --git a/engine/src/stirling/agents/pdf_create/agent.py b/engine/src/stirling/agents/pdf_create/agent.py index 671d4867ca..bb0d567186 100644 --- a/engine/src/stirling/agents/pdf_create/agent.py +++ b/engine/src/stirling/agents/pdf_create/agent.py @@ -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 diff --git a/engine/src/stirling/agents/pdf_edit.py b/engine/src/stirling/agents/pdf_edit.py index c46fdb9f24..5f89bdcc58 100644 --- a/engine/src/stirling/agents/pdf_edit.py +++ b/engine/src/stirling/agents/pdf_edit.py @@ -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, diff --git a/engine/src/stirling/agents/pdf_questions.py b/engine/src/stirling/agents/pdf_questions.py index f594c2fdd7..b17f799595 100644 --- a/engine/src/stirling/agents/pdf_questions.py +++ b/engine/src/stirling/agents/pdf_questions.py @@ -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()}" ) diff --git a/engine/src/stirling/agents/pdf_review.py b/engine/src/stirling/agents/pdf_review.py index 5aca029a8a..b4fc847a1d 100644 --- a/engine/src/stirling/agents/pdf_review.py +++ b/engine/src/stirling/agents/pdf_review.py @@ -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"{_escape_for_tag(user_message)}\n" f"{_escape_for_tag(report.model_dump_json())}" + f"\n{language_directive()}" ) result = await self._contradiction_localiser.run(prompt) specs = self._build_paired_comment_specs(report, result.output.comments) diff --git a/engine/src/stirling/agents/user_spec.py b/engine/src/stirling/agents/user_spec.py index 2ba367246b..cf1b378323 100644 --- a/engine/src/stirling/agents/user_spec.py +++ b/engine/src/stirling/agents/user_spec.py @@ -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( diff --git a/engine/src/stirling/contracts/orchestrator.py b/engine/src/stirling/contracts/orchestrator.py index 2d85853c51..d7cc9810a6 100644 --- a/engine/src/stirling/contracts/orchestrator.py +++ b/engine/src/stirling/contracts/orchestrator.py @@ -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 diff --git a/engine/src/stirling/services/__init__.py b/engine/src/stirling/services/__init__.py index 4488409142..618c2f8fef 100644 --- a/engine/src/stirling/services/__init__.py +++ b/engine/src/stirling/services/__init__.py @@ -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", ] diff --git a/engine/src/stirling/services/language.py b/engine/src/stirling/services/language.py new file mode 100644 index 0000000000..cfb802d77c --- /dev/null +++ b/engine/src/stirling/services/language.py @@ -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." + ) diff --git a/engine/tests/test_reply_language.py b/engine/tests/test_reply_language.py new file mode 100644 index 0000000000..ad4d9570aa --- /dev/null +++ b/engine/tests/test_reply_language.py @@ -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] diff --git a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx index 595961365f..9ec18089a2 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx @@ -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); });