diff --git a/docs/guides/user-management.md b/docs/guides/user-management.md index ada8aa47..fad69738 100644 --- a/docs/guides/user-management.md +++ b/docs/guides/user-management.md @@ -60,8 +60,9 @@ Each user has a preferred language recorded on their profile: a [BCP 47](https://datatracker.ietf.org/doc/html/rfc5646) tag, readable and settable through the API. -AI-generated course content and chat replies do not use this preference; it is -recorded and exposed for clients to read. +Chat replies and generated course content are written in this language. The preference +is resolved on every request, so changing it applies from the next message onward — +messages already sent are not rewritten. ### Supported languages diff --git a/sparkth/core/config.py b/sparkth/core/config.py index c0c9f143..eb8b9355 100644 --- a/sparkth/core/config.py +++ b/sparkth/core/config.py @@ -26,9 +26,10 @@ class LanguageInfo(NamedTuple): # The allowlist of languages the platform accepts: the values a user may pick as a -# preference, and the set DEFAULT_LANGUAGE is validated against. Nothing consumes a -# language when generating course content or chat replies — the allowlist bounds -# what may be stored and exposed, it is not a promise about generated output. +# preference, and the set DEFAULT_LANGUAGE is validated against. A resolved tag is +# injected into the chat system prompt, so generated replies and course content follow +# it — which is why membership is a reviewed decision, not a promise the model is +# equally strong in every listed language. # # Keys are BCP 47 tags (RFC 5646) — the hyphenated form HTML `lang`, # `Accept-Language` and the JS `Intl` API all consume; never the underscored POSIX diff --git a/sparkth/plugins/chat/assets/system_prompt.txt b/sparkth/plugins/chat/assets/system_prompt.txt index 7afb414b..629ee564 100644 --- a/sparkth/plugins/chat/assets/system_prompt.txt +++ b/sparkth/plugins/chat/assets/system_prompt.txt @@ -4,6 +4,13 @@ You are a learning design assistant trained in effective course creation. Your goal is to help users create high-quality online courses that are clear, engaging, and instructionally sound. Always write in a natural, conversational tone so the course feels authored by a human. +OUTPUT LANGUAGE +Write all of your replies and all course content — titles, descriptions, lesson text, assessment questions, answer options, and feedback — in {language_name}. +Do this regardless of the language the user writes to you in, and regardless of the language of any uploaded source documents. +Keep proper nouns, code identifiers, and established technical terms that have no accepted {language_name} equivalent in their original form. +Use natural, idiomatic {language_name} appropriate to the target audience — not a literal translation of English phrasing. +The single exception is the refusal sentence quoted below: reproduce it exactly as given, character for character, and do not translate it. It is supplied in the language it must be sent in. + SCOPE & GUARDRAILS You are strictly limited to course creation and instructional design tasks — including using available plugin tools to publish, retrieve, and manage courses on LMS platforms. You must not perform, respond to, or assist with anything outside this scope. @@ -60,7 +67,7 @@ Create the course outline and prompt for user's approval. Step 3. Develop Course Content Once the outline is approved, expand it into full course content. Balance clarity, depth, breadth, and cognitive load. -Write in the user's language and adapt tone to the audience. +Adapt tone to the audience. Generating assessments for each module/section is a MUST. Step 4. Suggest Visuals diff --git a/sparkth/plugins/chat/prompt.py b/sparkth/plugins/chat/prompt.py index c851e51d..13100a2b 100644 --- a/sparkth/plugins/chat/prompt.py +++ b/sparkth/plugins/chat/prompt.py @@ -4,6 +4,8 @@ from pathlib import Path from typing import cast +from sparkth.lib.language import SUPPORTED_LANGUAGES + _ASSETS_DIR = Path(__file__).parent / "assets" _scope_cfg: dict[str, object] | None = None @@ -39,10 +41,21 @@ def get_current_datetime() -> datetime: ] -def get_learning_design_system_prompt() -> str: +def get_learning_design_system_prompt(language: str) -> str: + """Render the learning-design system prompt for output in ``language``. + + ``language`` is an already-resolved, allowlisted BCP 47 tag — callers resolve a + user's stored preference with :func:`sparkth.lib.language.resolve_language` before + calling. The tag's English name is what reaches the model: it is the form an LLM + follows most reliably, and it keeps the directive readable in logs. + + Rendered fresh on every request, so a preference changed mid-conversation takes + effect on the very next turn; earlier messages stay as they were. + """ return _SYSTEM_PROMPT_TEMPLATE.format( current_datetime=get_current_datetime(), refusal_message=REFUSAL_MESSAGE, + language_name=SUPPORTED_LANGUAGES[language].name, ) diff --git a/sparkth/plugins/chat/routes/completions.py b/sparkth/plugins/chat/routes/completions.py index 5eb18420..6b8780f2 100644 --- a/sparkth/plugins/chat/routes/completions.py +++ b/sparkth/plugins/chat/routes/completions.py @@ -8,6 +8,7 @@ from sparkth.lib.auth import get_current_user from sparkth.lib.db import get_async_session +from sparkth.lib.language import resolve_language from sparkth.lib.llm import ( LLMConfigInactiveError, LLMConfigModelNotSetError, @@ -69,6 +70,9 @@ async def chat_completion( config: ChatSettings = Depends(get_chat_settings), ) -> Any: user_id: int = cast(int, current_user.id) + # Re-resolved per request, so a preference changed mid-conversation applies from + # the next turn onward. + language = resolve_language(current_user.language) try: llm_config, api_key = await llm_service.resolve( session=session, @@ -190,7 +194,7 @@ async def chat_completion( provider_name=provider_name, api_key=api_key, model=model, - system_prompt=get_learning_design_system_prompt(), + system_prompt=get_learning_design_system_prompt(language), temperature=request.temperature, max_tool_executions=config.max_tool_executions, ) diff --git a/sparkth/plugins/chat/tests/test_completion_language.py b/sparkth/plugins/chat/tests/test_completion_language.py new file mode 100644 index 00000000..f587af90 --- /dev/null +++ b/sparkth/plugins/chat/tests/test_completion_language.py @@ -0,0 +1,192 @@ +"""The resolved preferred language reaches the provider's system prompt. + +Template rendering is covered by test_prompt.py; this file pins the wiring — that +chat_completion resolves the signed-in user's stored preference, and that it re-resolves +per request, so a preference changed mid-conversation applies from the next turn. + +The mock stack mirrors test_intent_router_integration.py, which drives the same route. +""" + +import json +from typing import AsyncGenerator +from unittest.mock import AsyncMock, MagicMock, patch + +from httpx import AsyncClient +from sqlmodel.ext.asyncio.session import AsyncSession + +from sparkth.lib.encryption import get_encryption_service +from sparkth.lib.language import SUPPORTED_LANGUAGES +from sparkth.lib.models import LLMConfig, User +from sparkth.lib.settings import get_settings +from sparkth.plugins.chat.models import Conversation + + +class _Seeded: + def __init__(self, llm_config_id: int, conversation_uuid: str) -> None: + self.llm_config_id = llm_config_id + self.conversation_uuid = conversation_uuid + + +async def _seed(session: AsyncSession, user_id: int) -> _Seeded: + """An active LLM config plus an existing conversation. + + Posting into an existing conversation keeps the assertion on the language and off + the new-conversation path, which also schedules title generation. + """ + settings = get_settings() + encryption = get_encryption_service(settings.LLM_ENCRYPTION_KEY) + llm_config = LLMConfig( + user_id=user_id, + name="test-cfg-language", + provider="openai", + model="gpt-4o", + encrypted_key=encryption.encrypt("sk-test"), + masked_key="sk-***", + is_active=True, + ) + session.add(llm_config) + await session.flush() + llm_config_id = llm_config.id or 0 # capture before expiry + + conversation = Conversation( + user_id=user_id, + provider="openai", + model="gpt-4o", + llm_config_id=llm_config_id, + ) + session.add(conversation) + await session.flush() + conversation_uuid = str(conversation.uuid) + await session.commit() + return _Seeded(llm_config_id, conversation_uuid) + + +async def _fake_stream() -> AsyncGenerator[str, None]: + yield f"data: {json.dumps({'done': True})}\n\n" + + +def _other_supported_language_names(excluding_tag: str) -> set[str]: + """Names of every supported language other than ``excluding_tag``. + + Backs a negative control for the fallback test: the template's OUTPUT LANGUAGE + section also carries the unconditional literal "...not a literal translation of + English phrasing", so asserting only that the expected language's name is present + cannot tell the true default apart from some other valid tag landing here by + mistake. Asserting that none of these names appear catches that. + """ + return {info.name for tag, info in SUPPORTED_LANGUAGES.items() if tag != excluding_tag} + + +async def _system_prompt_for_one_request(client: AsyncClient, seed: _Seeded) -> str: + """Send one completion request; return the system prompt handed to the provider.""" + with ( + patch("sparkth.plugins.chat.routes.completions.get_provider") as mock_get_provider, + patch("sparkth.plugins.chat.routes.utils.is_query_in_scope", return_value=True), + patch("sparkth.plugins.chat.routes.utils.ScopeClassifier") as mock_classifier_cls, + patch( + "sparkth.plugins.chat.routes.completions.resolve_rag_intent", + new_callable=AsyncMock, + return_value=(False, None), + ), + patch("sparkth.plugins.chat.service.ChatService.add_message", new_callable=AsyncMock) as mock_add_message, + patch( + "sparkth.plugins.chat.service.ChatService.get_conversation_messages", + new_callable=AsyncMock, + return_value=[], + ), + patch( + "sparkth.plugins.chat.service.ChatService.list_conversation_attachments", + new_callable=AsyncMock, + return_value=[], + ), + patch("sparkth.plugins.chat.routes.completions.ChatStreamProcessor") as mock_processor_cls, + ): + mock_classifier = MagicMock() + mock_classifier.classify = AsyncMock(return_value=True) + mock_classifier_cls.return_value = mock_classifier + + mock_message = MagicMock() + mock_message.id = 1 + mock_add_message.return_value = mock_message + + mock_provider = MagicMock() + mock_provider.system_prompt = "" + mock_provider.create_llm.return_value = MagicMock() + mock_get_provider.return_value = mock_provider + + mock_processor = MagicMock() + mock_processor.stream.return_value = _fake_stream() + mock_processor_cls.return_value = mock_processor + + response = await client.post( + "/api/v1/chat/completions", + json={ + "llm_config_id": seed.llm_config_id, + "messages": [{"role": "user", "content": "Create a course on data privacy"}], + "conversation_id": seed.conversation_uuid, + "stream": True, + "tools": "none", + }, + ) + + assert response.status_code == 200 + return str(mock_get_provider.call_args.kwargs["system_prompt"]) + + +class TestCompletionLanguageWiring: + """The `current_user` fixture overrides the auth dependency with an in-memory User, + so setting `.language` on it is the whole of "the user chose this language" — the + row is never read back from the database.""" + + async def test_stored_preference_reaches_the_system_prompt( + self, + client: AsyncClient, + current_user: User, + session: AsyncSession, + ) -> None: + seed = await _seed(session, current_user.id or 1) + current_user.language = "es" + + prompt = await _system_prompt_for_one_request(client, seed) + + assert SUPPORTED_LANGUAGES["es"].name in prompt + + async def test_unset_preference_falls_back_to_the_platform_default( + self, + client: AsyncClient, + current_user: User, + session: AsyncSession, + ) -> None: + seed = await _seed(session, current_user.id or 1) + current_user.language = None + + prompt = await _system_prompt_for_one_request(client, seed) + + default_tag = get_settings().DEFAULT_LANGUAGE + assert SUPPORTED_LANGUAGES[default_tag].name in prompt + + # Negative control: see _other_supported_language_names for why the + # positive assertion alone cannot tell the true default from a + # valid-but-wrong resolution — the template names "English" unconditionally, + # so that assertion alone would pass even if the fallback resolved to any + # other supported language. + assert not any(name in prompt for name in _other_supported_language_names(default_tag)) + + async def test_changing_the_preference_applies_to_the_next_turn( + self, + client: AsyncClient, + current_user: User, + session: AsyncSession, + ) -> None: + """The prompt is rebuilt per request, so no conversation-level pinning exists + and none should be added: the next turn simply switches language.""" + seed = await _seed(session, current_user.id or 1) + + current_user.language = "es" + first = await _system_prompt_for_one_request(client, seed) + current_user.language = "fr" + second = await _system_prompt_for_one_request(client, seed) + + assert SUPPORTED_LANGUAGES["es"].name in first + assert SUPPORTED_LANGUAGES["fr"].name in second + assert SUPPORTED_LANGUAGES["es"].name not in second diff --git a/sparkth/plugins/chat/tests/test_prompt.py b/sparkth/plugins/chat/tests/test_prompt.py index 988fb704..f8ebc263 100644 --- a/sparkth/plugins/chat/tests/test_prompt.py +++ b/sparkth/plugins/chat/tests/test_prompt.py @@ -1,9 +1,12 @@ +import pytest + +from sparkth.lib.language import SUPPORTED_LANGUAGES from sparkth.plugins.chat.prompt import REFUSAL_MESSAGE, get_learning_design_system_prompt class TestLearningDesignSystemPrompt: def setup_method(self) -> None: - self.prompt = get_learning_design_system_prompt() + self.prompt = get_learning_design_system_prompt("en") def test_scope_and_guardrails_section_present(self) -> None: assert "SCOPE & GUARDRAILS" in self.prompt @@ -17,3 +20,39 @@ def test_allowed_tasks_section_present_and_non_empty(self) -> None: def test_refusal_sentence_present_verbatim(self) -> None: assert REFUSAL_MESSAGE in self.prompt + + +class TestSystemPromptLanguage: + """The prompt must name the output language explicitly, and must no longer + contain the ambiguous instruction it replaces.""" + + @pytest.mark.parametrize("tag", sorted(SUPPORTED_LANGUAGES)) + def test_names_the_language_for_every_supported_tag(self, tag: str) -> None: + prompt = get_learning_design_system_prompt(tag) + assert SUPPORTED_LANGUAGES[tag].name in prompt + + @pytest.mark.parametrize("tag", sorted(SUPPORTED_LANGUAGES)) + def test_no_unrendered_placeholder_remains(self, tag: str) -> None: + assert "{language_name}" not in get_learning_design_system_prompt(tag) + + def test_ambiguous_language_instruction_is_gone(self) -> None: + """ "Write in the user's language" reads as the language they typed in, which is + not their configured preference. It must be replaced, not supplemented, or the + model gets contradictory guidance. + + Asserts on the exact deleted sentence rather than banning the phrase family: + the new directive legitimately says "regardless of the language the user writes + to you in", and a broader assertion would fail on a harmless rewording.""" + assert "Write in the user's language" not in get_learning_design_system_prompt("es") + + def test_directive_covers_content_not_just_replies(self) -> None: + prompt = get_learning_design_system_prompt("es") + for part in ("assessment questions", "answer options", "feedback"): + assert part in prompt + + def test_refusal_sentence_is_carved_out_of_the_directive(self) -> None: + """The template hands the model the refusal sentence and says to send it + verbatim. Without an explicit exception the language directive contradicts + that, and the model's refusal drifts from the deterministic streamed one.""" + prompt = get_learning_design_system_prompt("es") + assert "reproduce it exactly as given" in prompt