mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
+3
@@ -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;
|
||||
}
|
||||
|
||||
+5
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user