Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/guides/user-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions sparkth/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion sparkth/plugins/chat/assets/system_prompt.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion sparkth/plugins/chat/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
)


Expand Down
6 changes: 5 additions & 1 deletion sparkth/plugins/chat/routes/completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down
192 changes: 192 additions & 0 deletions sparkth/plugins/chat/tests/test_completion_language.py
Original file line number Diff line number Diff line change
@@ -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
41 changes: 40 additions & 1 deletion sparkth/plugins/chat/tests/test_prompt.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Loading