Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
72 changes: 72 additions & 0 deletions src/backend/alembic/versions/005_add_helping_phrases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Add helping phrases table for bilingual practice support.

Revision ID: 005_add_helping_phrases
Revises: 004_add_jwt_auth
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration header comment says "Revises: 004_add_jwt_auth" but down_revision points to "ef47914574a5". Update the docstring metadata so it matches the actual Alembic dependency chain to avoid confusion during maintenance/review.

Suggested change
Revises: 004_add_jwt_auth
Revises: ef47914574a5

Copilot uses AI. Check for mistakes.
Create Date: 2026-01-28

Helping phrases allow students to request assistance during practice
in their native language (e.g., "No entiendo", "Repite, por favor").
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "005_add_helping_phrases"
down_revision: Union[str, None] = "ef47914574a5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# Create helping_phrases table
op.create_table(
"helping_phrases",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("language_code", sa.String(10), nullable=False),
sa.Column("phrase_key", sa.String(50), nullable=False),
sa.Column("phrase_text", sa.String(200), nullable=False),
sa.Column("english_meaning", sa.String(200), nullable=False),
sa.Column("usage_context", sa.Text(), nullable=True),
sa.Column("display_order", sa.Integer(), default=0),
sa.Column(
"created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("language_code", "phrase_key", name="uq_helping_phrase_lang_key"),
)

# Create index for language lookups
op.create_index(
"ix_helping_phrases_language_code",
"helping_phrases",
["language_code"],
)

# Seed Spanish helping phrases
op.execute(
"""
INSERT INTO helping_phrases (language_code, phrase_key, phrase_text, english_meaning, usage_context, display_order) VALUES
('es', 'repeat', 'Repite, por favor', 'Please repeat', 'When you did not catch what was said', 1),
('es', 'dont_understand', 'No entiendo', 'I don''t understand', 'When you need an explanation', 2),
('es', 'slower', 'Más despacio, por favor', 'Slower, please', 'When speaking is too fast', 3),
('es', 'example', 'Dame un ejemplo', 'Give me an example', 'When you need a concrete example', 4)
"""
)

# Seed English helping phrases (for English-speaking learners)
op.execute(
"""
INSERT INTO helping_phrases (language_code, phrase_key, phrase_text, english_meaning, usage_context, display_order) VALUES
('en', 'repeat', 'Please repeat', 'Please repeat', 'When you did not catch what was said', 1),
('en', 'dont_understand', 'I don''t understand', 'I don''t understand', 'When you need an explanation', 2),
('en', 'slower', 'Slower, please', 'Slower, please', 'When speaking is too fast', 3),
('en', 'example', 'Give me an example', 'Give me an example', 'When you need a concrete example', 4)
"""
)


def downgrade() -> None:
op.drop_index("ix_helping_phrases_language_code", table_name="helping_phrases")
op.drop_table("helping_phrases")
60 changes: 60 additions & 0 deletions src/backend/app/agents/unified_teaching_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from app.models.performance import PerformanceContext
from app.prompts import load_prompt, render_prompt
from app.schemas.helping_phrase import HelpingPhraseSchema
from app.schemas.lesson import LessonDetail


Expand All @@ -33,6 +34,7 @@ def __init__(
instruction_language: str = "es",
performance_context: Optional[PerformanceContext] = None,
focus_pattern: Optional[int] = None,
helping_phrases: Optional[list[HelpingPhraseSchema]] = None,
):
"""Initialize the unified teaching agent.

Expand All @@ -43,13 +45,15 @@ def __init__(
instruction_language: Language for explanations ('es' or 'en', default 'es')
performance_context: Optional tracking of student struggle signals
focus_pattern: Optional pattern number to focus practice on
helping_phrases: Optional list of helping phrases for the instruction language
"""
self.lesson = lesson
self.mode = mode
self.exchange_count = exchange_count
self.instruction_language = instruction_language
self.performance_context = performance_context or PerformanceContext()
self.focus_pattern = focus_pattern
self.helping_phrases = helping_phrases or []

def build_system_prompt(self) -> str:
"""Build mode-specific system prompt for the LLM.
Expand Down Expand Up @@ -127,6 +131,11 @@ def _build_practice_prompt(self) -> str:
# Focus pattern instruction
focus_instruction = self._get_focus_instruction()

# Helping phrases formatting
helping_phrases_list = self._format_helping_phrases_list()
helping_phrases_formatted = self._format_helping_phrases_for_intro()
pattern_introduction = self._format_pattern_introduction()

template = load_prompt("agent/mode_practice.md")
return render_prompt(
template,
Expand All @@ -139,6 +148,9 @@ def _build_practice_prompt(self) -> str:
instruction_language=instruction_lang_text,
flip_instruction=flip_instruction,
focus_instruction=focus_instruction,
helping_phrases_list=helping_phrases_list,
helping_phrases_formatted=helping_phrases_formatted,
pattern_introduction=pattern_introduction,
)

def _get_focus_instruction(self) -> str:
Expand Down Expand Up @@ -193,3 +205,51 @@ def _get_flip_instruction(self) -> str:
return "Time to flip! Prompt the student to ask YOU a question now."
else:
return "Natural conversation - mix asking and answering. The student may ask you questions."

def _format_helping_phrases_list(self) -> str:
"""Format helping phrases as a simple list for recognition."""
if not self.helping_phrases:
return "No helping phrases available."

lines = []
for p in self.helping_phrases:
lines.append(f'- "{p.phrase_text}" = {p.english_meaning}')
return "\n".join(lines)

def _format_helping_phrases_for_intro(self) -> str:
"""Format helping phrases for the session introduction."""
if not self.helping_phrases:
return "No helping phrases available."

# Just list the phrase texts for the intro
phrases = [f'"{p.phrase_text}"' for p in self.helping_phrases[:3]] # Limit to 3 for brevity
if self.instruction_language == "es":
return f" Puedes decir: {', '.join(phrases)}"
else:
return f" You can say: {', '.join(phrases)}"

def _format_pattern_introduction(self) -> str:
"""Format pattern introduction for session start."""
if not self.lesson.patterns:
return " No patterns to practice."

# Get the first pattern (or focus pattern if set)
pattern = self.lesson.patterns[0]
if self.focus_pattern:
for p in self.lesson.patterns:
if p.pattern_number == self.focus_pattern:
pattern = p
break

# Format based on instruction language
if self.instruction_language == "es":
q_template = pattern.question_translation or pattern.question_template
a_template = pattern.answer_translation or pattern.answer_template
return f""" Show the pattern in Spanish:
- Pregunta: "{q_template}"
- Respuesta: "{a_template}"
- Then show English: Q: "{pattern.question_template}" → A: "{pattern.answer_template}" """
else:
return f""" Show the pattern in English:
- Question: "{pattern.question_template}"
- Answer: "{pattern.answer_template}" """
20 changes: 20 additions & 0 deletions src/backend/app/models/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,23 @@ class ExampleSentence(Base):
# Relationships
lesson: Mapped["Lesson"] = relationship()
pattern: Mapped["QAPattern"] = relationship()


class HelpingPhrase(Base):
"""Helping phrases for learners to request assistance during practice.

These phrases allow students to ask for help in their native language
(e.g., "No entiendo" in Spanish) and trigger the help recovery flow
where the agent explains in the student's language then restarts practice.
"""

__tablename__ = "helping_phrases"

id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
language_code: Mapped[str] = mapped_column(String(10), nullable=False) # 'es', 'en', 'fr'
phrase_key: Mapped[str] = mapped_column(String(50), nullable=False) # 'repeat', 'dont_understand'
phrase_text: Mapped[str] = mapped_column(String(200), nullable=False) # "Repite, por favor"
english_meaning: Mapped[str] = mapped_column(String(200), nullable=False) # "Please repeat"
usage_context: Mapped[str | None] = mapped_column(Text) # When to use this phrase
display_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
115 changes: 100 additions & 15 deletions src/backend/app/prompts/agent/mode_practice.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
You are a conversation partner helping the student practice Q&A patterns through natural dialogue.
{focus_instruction}

## Language Configuration

- **Instruction Language:** {instruction_language} (for explanations and instructions)
- **Practice Language:** English (target language being learned)

## Patterns to Practice

{patterns_list}
Expand All @@ -11,20 +16,80 @@ You are a conversation partner helping the student practice Q&A patterns through

{vocab_list}

## Help Phrases the Student Can Use

{helping_phrases_list}

---

## Two-Phase Practice Flow

### Session Introduction (exchange_count == 0 only)

**When this is the FIRST exchange**, you MUST:

1. **Explain the pattern in {instruction_language}:**
{pattern_introduction}

2. **Introduce helping phrases (in {instruction_language}):**
Tell the student they can say these phrases if they need help:
{helping_phrases_formatted}

3. **Transition to practice:**
Then say something like (in {instruction_language}):
- If Spanish: "¡Ahora vamos a practicar en inglés!"
- If English: "Now let's practice in English!"

4. **Start with YOUR first question IN ENGLISH.**

### Ongoing Practice (exchange_count > 0)

Follow the normal conversation flow below.

---

## Conversation Flow

**Exchange Count: {exchange_count}**

### Phase 1: You Lead (exchanges 0-2)
Ask questions using the patterns above. Wait for student responses.
Ask questions using the patterns above IN ENGLISH. Wait for student responses.

### Phase 2: Prompt the Flip (exchanges 3-5)
After 3-5 exchanges, prompt the student to ask YOU a question:
- "Now it's your turn! Can you ask me a question using the pattern?"
- "¡Ahora te toca a ti! Can you ask me something?"
- In {instruction_language}: prompt them to try asking you

### Phase 3: Natural Conversation (exchanges 5+)
Continue natural back-and-forth. Sometimes you ask, sometimes they ask.
Continue natural back-and-forth IN ENGLISH. Sometimes you ask, sometimes they ask.

---

## Help Recovery Protocol

**CRITICAL:** If the student says a help phrase (from the list above), you MUST:

1. **Acknowledge in {instruction_language}:**
- Spanish: "Claro, te explico..."
- English: "Of course, let me explain..."

2. **Explain the current pattern simply in {instruction_language}:**
- Show the pattern structure
- Give a simple example

3. **Transition back to practice:**
- Say (in {instruction_language}): "Let's try again from the beginning."

4. **RESTART the pattern IN ENGLISH:**
- Ask your question again using the pattern

**Example Help Recovery Flow:**

Student says: "No entiendo"
YOU: speak("Claro. El patrón es: 'What do you eat for breakfast?' y respondes 'I eat [food] for breakfast.' Por ejemplo: 'I eat eggs for breakfast.' Vamos a intentar otra vez.", language="es")
YOU: speak("What do you eat for breakfast?", language="en")

---

## Student Performance

Expand All @@ -38,8 +103,27 @@ Continue natural back-and-forth. Sometimes you ask, sometimes they ask.
2. **Use get_teaching_help when struggling** - If the student makes errors or asks for help, retrieve additional examples.
3. **Record attempts** - Call record_attempt after each student response to track progress.
4. **Stay encouraging** - Celebrate correct answers, gently correct mistakes.
5. **Explain in {instruction_language}** - Use their preferred language for explanations.
6. **Redirect personal questions** - See below.
5. **ALL support in {instruction_language}** - Encouragement, corrections, clarifications, and advice MUST be in {instruction_language}.
6. **Practice sentences in English** - Only the actual pattern Q&A sentences are in English.
7. **Redirect personal questions** - See below.

## Language Rules (CRITICAL)

**Use {instruction_language} for:**
- Encouragement: "¡Muy bien!" / "Great job!"
- Corrections: "Casi, pero..." / "Almost, but..."
- Clarifications: "Recuerda que..." / "Remember that..."
- Advice: "Intenta decir..." / "Try saying..."
- Transitions: "Ahora pregúntame tú" / "Now you ask me"

**Use English ONLY for:**
- The actual pattern questions: "What do you eat for breakfast?"
- The actual pattern answers: "I eat eggs for breakfast."

**Example (Spanish instruction_language):**
Student: "I eat breakfast eggs"
YOU: speak("Casi, pero el orden es diferente. Debería ser 'I eat eggs for breakfast.' ¡Inténtalo de nuevo!", language="es")
YOU: speak("What do you eat for breakfast?", language="en")

## Personal Questions - REDIRECT TO STUDENT

Expand Down Expand Up @@ -67,17 +151,18 @@ Brief fictional examples for demonstration are OK:

## Example Flow

Exchange 0:
YOU: speak("What do you like to do?", language="en")
**Exchange 0 (Session Start with Spanish instruction_language):**
YOU: speak("Hoy vamos a practicar el patrón: 'What do you eat for breakfast?' y responder 'I eat [comida] for breakfast.' Si necesitas ayuda, puedes decir 'No entiendo' o 'Repite, por favor'. ¡Ahora vamos a practicar en inglés!", language="es")
YOU: speak("What do you eat for breakfast?", language="en")

Exchange 1 (student responds):
STUDENT: "I like to play soccer"
**Exchange 1 (student responds):**
STUDENT: "I eat eggs for breakfast"
YOU: record_attempt(item_type="pattern", correct=True)
YOU: speak("Great! Soccer is fun. Do you play every week?", language="en")
YOU: speak("Great! Eggs are delicious. What do you eat for lunch?", language="en")

Exchange 3 (time to flip):
YOU: speak("Now you ask me a question! Use the pattern: 'What do you like to do?'", language="en")
**Exchange 3 (time to flip):**
YOU: speak("Now you ask me a question! Use the pattern: 'What do you eat for breakfast?'", language="en")

Exchange 4 (student asks):
STUDENT: "What do you like to do?"
YOU: speak("I like to read books and cook dinner.", language="en")
**Exchange 4 (student asks):**
STUDENT: "What do you eat for breakfast?"
YOU: speak("I eat toast and fruit for breakfast. What about dinner - what do you eat?", language="en")
Loading
Loading