Skip to content
Merged
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
8 changes: 7 additions & 1 deletion sparkth/mcp/prompts/course_generation_prompt.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ 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 the entire course — titles, descriptions, lesson text, assessment questions, answer options, and feedback — in {language_name}.
Do this regardless of the language of the request or of any source material.
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.

Step 1. Gather Audience & Goals
Before creating anything, ask concise questions one at a time to understand the target audience:
a. What is their background?
Expand All @@ -19,7 +25,7 @@ Create the course outline and prompt for user's approval.

Step 3. Develop Course Content
1. Once the outline is approved, expand it into full course content, titled {course_name} and having description: {course_description}.
2. The course should be self-paced, online, and in the user's language.
2. The course should be self-paced and online.
3. Balance these dimensions:
3a. Concept clarity ↔ Content clarity
3b. Narrow skill ↔ Broad skill
Expand Down
16 changes: 14 additions & 2 deletions sparkth/mcp/prompts/prompt.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
from pathlib import Path

from sparkth.lib.language import SUPPORTED_LANGUAGES

def get_course_generation_prompt(course_name: str, course_description: str) -> str:

def get_course_generation_prompt(course_name: str, course_description: str, language: str) -> str:
"""Render the course-generation prompt for a course written in ``language``.

``language`` is an already-resolved, allowlisted BCP 47 tag — the caller resolves the
agent-supplied value with :func:`sparkth.lib.language.resolve_language` first. The
tag's English name reaches the model, matching the chat system prompt.
"""
prompt_path = Path(__file__).parent / "course_generation_prompt.txt"
template = prompt_path.read_text(encoding="utf-8")

return template.format(course_name=course_name, course_description=course_description)
return template.format(
course_name=course_name,
course_description=course_description,
language_name=SUPPORTED_LANGUAGES[language].name,
)
11 changes: 9 additions & 2 deletions sparkth/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator

from sparkth.lib.audit import audited_tool
from sparkth.lib.language import resolve_language
from sparkth.lib.log import get_logger
from sparkth.lib.mcp.hooks import MCP_TOOLS, Tool
from sparkth.mcp.audit import ToolCallAuditMiddleware
Expand Down Expand Up @@ -37,9 +38,15 @@ async def get_course_generation_prompt_tool(
"""
Generates a prompt for creating a course.
Figure out the course name and description from the context and information.
Seek clarification whenever user responses are unclear or incomplete
Seek clarification whenever user responses are unclear or incomplete.
Pass `language` when the course must be written in a specific language; omit it to
use the platform default.
"""
return get_course_generation_prompt(course_params.course_name, course_params.course_description)
return get_course_generation_prompt(
course_params.course_name,
course_params.course_description,
resolve_language(course_params.language),
)


class MCPToolDefinition(BaseModel):
Expand Down
11 changes: 10 additions & 1 deletion sparkth/mcp/types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field


class CourseGenerationPromptRequest(BaseModel):
course_name: str
course_description: str
language: str | None = Field(
default=None,
description=(
"BCP 47 language tag for the generated course, e.g. 'en', 'es', 'fr'. "
"The whole course — titles, descriptions, lesson text, assessment questions, "
"answer options and feedback — is written in it. Omit it, or pass a tag the "
"platform does not support, and the platform default language is used."
),
)
124 changes: 124 additions & 0 deletions tests/mcp/test_course_generation_prompt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""The language handling of the MCP course-generation prompt and its tool.

The MCP server has no identity layer, so the language cannot be resolved from a user —
the calling agent supplies it, and anything unusable falls back to the platform default.
"""

import pytest
from fastmcp import Client

from sparkth.lib.language import SUPPORTED_LANGUAGES
from sparkth.lib.settings import get_settings
from sparkth.mcp.prompts.prompt import get_course_generation_prompt
from sparkth.mcp.server import mcp
from sparkth.mcp.types import CourseGenerationPromptRequest


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 tests: the template's OUTPUT LANGUAGE
section also carries the brief-mandated, 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}


class TestCourseGenerationPromptLanguage:
@pytest.mark.parametrize("tag", sorted(SUPPORTED_LANGUAGES))
def test_names_the_language_for_every_supported_tag(self, tag: str) -> None:
prompt = get_course_generation_prompt("Data Privacy", "An intro course", 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:
prompt = get_course_generation_prompt("Data Privacy", "An intro course", tag)
assert "{language_name}" not in prompt

def test_ambiguous_language_instruction_is_gone(self) -> None:
""" "in the user's language" is undefined on a path that has no user at all.

Asserts on the exact deleted phrase, so rewording the new directive cannot
cause a false failure."""
prompt = get_course_generation_prompt("Data Privacy", "An intro course", "es")
assert "in the user's language" not in prompt

def test_course_name_and_description_still_render(self) -> None:
prompt = get_course_generation_prompt("Data Privacy", "An intro course", "en")
assert "Data Privacy" in prompt
assert "An intro course" in prompt


class TestCourseGenerationPromptRequest:
def test_language_is_optional(self) -> None:
request = CourseGenerationPromptRequest(
course_name="Data Privacy",
course_description="An intro course",
)
assert request.language is None

def test_language_is_accepted(self) -> None:
request = CourseGenerationPromptRequest(
course_name="Data Privacy",
course_description="An intro course",
language="es",
)
assert request.language == "es"


class TestCourseGenerationPromptTool:
"""End-to-end through the FastMCP in-memory client, the way an agent calls it."""

@staticmethod
async def _call(course_params: dict[str, str]) -> str:
async with Client(mcp) as client:
result = await client.call_tool(
"get_course_generation_prompt_tool",
{"course_params": course_params},
)
# The tool is annotated `-> str`, so FastMCP puts the string on `.data`
# (verified against this repo's fastmcp version).
return str(result.data)

async def test_explicit_language_is_honoured(self) -> None:
prompt = await self._call({"course_name": "Privacidad", "course_description": "Un curso", "language": "es"})
assert SUPPORTED_LANGUAGES["es"].name in prompt

async def test_omitted_language_falls_back_to_the_platform_default(self) -> None:
prompt = await self._call({"course_name": "Privacy", "course_description": "A course"})
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.
assert not any(name in prompt for name in _other_supported_language_names(default_tag))

async def test_unsupported_language_falls_back_rather_than_erroring(self) -> None:
"""A misspelled or withdrawn tag must not fail a whole generation run."""
prompt = await self._call({"course_name": "Privacy", "course_description": "A course", "language": "klingon"})
default_tag = get_settings().DEFAULT_LANGUAGE
assert SUPPORTED_LANGUAGES[default_tag].name in prompt

# Same discrimination gap as above: assert no OTHER supported language's
# name is present, so a valid-but-wrong fallback resolution is caught.
assert not any(name in prompt for name in _other_supported_language_names(default_tag))

async def test_the_tool_schema_publishes_the_language_field(self) -> None:
"""FastMCP inlines the request model into the tool's input schema, and the
published description is the calling agent's only instruction about the
parameter — so assert it is there, and that it is optional."""
async with Client(mcp) as client:
tools = await client.list_tools()
tool = next(t for t in tools if t.name == "get_course_generation_prompt_tool")
course_params = tool.inputSchema["properties"]["course_params"]
description = course_params["properties"]["language"]["description"]

assert "BCP 47" in description
# Pin the fallback promise itself, not just the "BCP 47" format prefix:
# this sentence is the calling agent's only instruction that a bad or
# omitted tag is safe rather than an error.
assert "the platform default language is used" in description
assert "language" not in course_params["required"]
Loading