# English Speaking Task Hint Modal — Design

**Date**: 2026-04-25
**Branch**: `feat/english-speaking`
**Repos affected**:
- Frontend: `/Users/buoy/Development/gitrepo/PPT`
- Backend: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`

## Goal

Replace the hardcoded task hint modal inside `DialogueChatView.vue` with a dedicated component and a lazy-loaded backend endpoint. The hint should use the same session inputs as the dialogue prompt: grade, topic, key vocabulary, and key sentence patterns.

The MVP should keep the user-facing flow fast and robust:

- Creating a dialogue session must not wait for task-hint generation.
- The task hint is generated only when the student clicks the hint button.
- The frontend never consumes raw LLM output.
- The backend always returns a valid `TaskHint` object, using deterministic fallback content when LLM output cannot be parsed or validated.

## Non-goals

- No task-hint generation during `POST /session`.
- No multi-request LLM repair loop.
- No model-specific dependency on structured-output APIs.
- No strict JSON Schema mode requirement for MVP.
- No frontend prompt construction.
- No frontend parsing of LLM text.
- No redesign of the modal visual style beyond moving it into a component.

## Current State

`DialogueChatView.vue` currently owns all task hint UI and content:

- The hint modal template is inline.
- `sentenceHints` and `vocabHints` are static frontend arrays.
- The current question is a frontend template using `aiName` and `topic`.
- The hint button directly toggles `showHintModal`.

Session creation already sends the required source inputs through `RealDialogueAPI.createSession()`:

```ts
{
  topic: config.topic,
  grade: config.grade,
  vocabulary: config.vocabulary ?? [],
  sentences: config.sentences ?? [],
  totalRounds: config.totalRounds,
  roleId: config.roleId,
}
```

## Architecture

```text
User clicks "提示"
  ↓
DialogueChatView.openTaskHint()
  ↓
show TaskHintModal immediately
  ↓
if cached hint exists for this mounted session:
  render cached hint
else:
  POST /api/speaking/dialogue/session/{sessionId}/task-hint
    ↓
  Backend reads session grade/topic/vocabulary/sentences
    ↓
  if cached TaskHint exists for this session:
    return cached TaskHint without calling the LLM
  else:
    Single LLM request asks for JSON text
      ↓
    Backend parses + validates + normalizes
      ↓
    if valid: persist and return normalized TaskHint
    if invalid: persist and return deterministic fallback TaskHint
```

## Frontend Design

### Component Extraction

Create:

```text
src/views/Editor/EnglishSpeaking/preview/TaskHintModal.vue
```

`TaskHintModal.vue` is presentational. It does not know about sessions, APIs, prompts, or LLMs.

Props:

```ts
interface TaskHintModalProps {
  visible: boolean
  loading: boolean
  error?: string | null
  hint?: TaskHint | null
  aiName?: string
}
```

Emits:

```ts
close
retry
```

The component renders four states:

- Hidden when `visible === false`
- Loading while the request is in flight
- Error with retry action when generation fails at the transport layer
- Content when `hint` is available

Backend parse or schema failure should not normally produce the error state because the backend returns fallback content. The frontend error state is for network, auth, 5xx, or aborted request failures.

### DialogueChatView State

`DialogueChatView.vue` should own the API call and cache for the mounted session:

```ts
const showHintModal = ref(false)
const taskHint = ref<TaskHint | null>(null)
const taskHintLoading = ref(false)
const taskHintError = ref<string | null>(null)
```

Functions:

```ts
function openTaskHint() {
  showHintModal.value = true
  if (!taskHint.value && !taskHintLoading.value) {
    loadTaskHint()
  }
}

async function loadTaskHint() {
  if (!props.sessionInfo?.sessionId) {
    taskHintError.value = '当前会话未准备好，请稍后重试'
    return
  }

  taskHintLoading.value = true
  taskHintError.value = null

  try {
    taskHint.value = await api.generateTaskHint(props.sessionInfo.sessionId)
  } catch {
    taskHintError.value = '生成任务提示失败，请重试'
  } finally {
    taskHintLoading.value = false
  }
}
```

The hint button should call `openTaskHint()` instead of assigning `showHintModal = true`.

### Frontend Types

Add to `src/types/englishSpeaking.ts`:

```ts
export interface TaskHint {
  practice_level: string
  conversation_topic: string
  current_question: string
  example_sentences: {
    english: string
    chinese: string
  }[]
  key_vocabulary: {
    word: string
    meaning: string
  }[]
}
```

Extend `DialogueAPI`:

```ts
generateTaskHint(sessionId: string): Promise<TaskHint>
```

### Frontend API

Add to `RealDialogueAPI`:

```ts
async generateTaskHint(sessionId: string): Promise<TaskHint> {
  const res = await fetch(`${API_BASE}/session/${sessionId}/task-hint`, {
    method: 'POST',
    credentials: 'include',
  })

  if (!res.ok) {
    throw new DialogueApiError(`task hint failed: ${res.status}`, res.status)
  }

  return await res.json()
}
```

`MockDialogueAPI.generateTaskHint()` should return a local mock object shaped like `TaskHint`.

## Backend API

Add:

```text
POST /api/speaking/dialogue/session/{sessionId}/task-hint
```

Response:

```json
{
  "practice_level": "grade5-1",
  "conversation_topic": "How I get to school",
  "current_question": "和 Tom 聊一聊 How I get to school，试着用今天的重点词汇和句型表达你的想法。",
  "example_sentences": [
    {
      "english": "I come to school by bus.",
      "chinese": "我乘公交车来学校。"
    },
    {
      "english": "I live near my school.",
      "chinese": "我住得离学校很近。"
    },
    {
      "english": "It takes me ten minutes to get there.",
      "chinese": "我到那里需要十分钟。"
    }
  ],
  "key_vocabulary": [
    {
      "word": "by bus",
      "meaning": "乘公交车"
    },
    {
      "word": "on foot",
      "meaning": "步行"
    },
    {
      "word": "near",
      "meaning": "附近的"
    },
    {
      "word": "far from",
      "meaning": "离……远"
    }
  ]
}
```

The endpoint reads course variables from the existing session record. The frontend should not resend grade, topic, vocabulary, or sentences for this endpoint.

The endpoint is idempotent per dialogue session:

- If a task hint has already been generated or fallback-created for the session, return the stored `TaskHint` directly.
- Do not call the LLM again for the same session after the first successful endpoint completion.
- This backend cache is required in addition to the frontend's mounted-component cache, so refreshing the page or remounting the chat view still reuses the existing hint.

Storage can be implemented with a JSON column on the dialogue session, a separate one-row-per-session table, or an existing session metadata store. For MVP, prefer the smallest storage change that fits the current backend schema.

## LLM Generation Strategy

MVP decision:

```text
Use one LLM request that asks for JSON text.
Do not use a retry/repair request.
Do not require provider-specific structured output.
If the provider supports JSON mode, the backend may enable it.
Regardless of provider support, always parse and validate server-side.
On parse or validation failure, return deterministic fallback content.
Persist whichever valid `TaskHint` is returned, including fallback.
```

This keeps the MVP provider-agnostic while still benefiting from JSON mode when available in providers such as Qwen, DeepSeek, Kimi, or GLM.

### Prompt Requirements

The prompt should ask for exactly one JSON object and no Markdown code block. It should include:

- `practice_level`: direct session grade
- `conversation_topic`: direct session topic
- `current_question`: 1-2 Chinese sentences, friendly and encouraging
- `example_sentences`: exactly 3 objects with `english` and `chinese`
- `key_vocabulary`: exactly 4 objects with `word` and `meaning`

Correct the original placeholder typos:

- `{{重点词汇)}}` becomes `{{重点词汇}}`
- `{{重点句型)}}` becomes `{{重点句型}}`

The example in the prompt must also contain exactly 3 example sentences and 4 vocabulary items so the example does not contradict the rules.

### Validation Rules

The backend validates the parsed object:

- Root is an object.
- Required string fields exist and are non-empty.
- `example_sentences` is an array.
- Each example sentence has non-empty `english` and `chinese`.
- `key_vocabulary` is an array.
- Each vocabulary item has non-empty `word` and `meaning`.

Normalization:

- Trim strings.
- Keep the first 3 valid example sentences.
- Keep the first 4 valid vocabulary items.
- If too few valid items remain, fill from deterministic fallback.
- Ignore unknown fields.

## Fallback Strategy

Fallback is local code, not another LLM request.

Use session values:

```ts
current_question =
  `和 Tom 聊一聊「${topic}」，试着用今天的重点词汇和句型表达你的想法。`
```

For `example_sentences`:

- Prefer session `sentences` as the English side.
- Use `"参考译文待补充"` as a safe MVP Chinese placeholder if translation is unavailable.
- Fill to 3 items with simple topic-safe sentence templates.

For `key_vocabulary`:

- Prefer session `vocabulary`.
- Fill to 4 items with topic-safe common words only if needed.
- Use `"重点词汇"` as a safe MVP meaning when no local dictionary is available.

The fallback does not need to be as polished as LLM output. It only needs to keep the modal usable and the response contract valid.

## Error Handling

Backend:

- Missing session: return 404.
- Existing cached task hint: return it with 200 and skip LLM generation.
- LLM provider/network failure: return fallback `TaskHint` with 200 unless the service cannot read the session.
- LLM parse/schema failure: return fallback `TaskHint` with 200.
- Unexpected server error before fallback construction: return 500.

Frontend:

- 404 or 5xx: show modal error state with retry.
- Successful fallback response: render content normally.
- Reopening the modal in the same mounted session uses cached `taskHint`.

## Testing

Frontend:

- `TaskHintModal` renders loading, error, and content states.
- `DialogueChatView` calls `generateTaskHint()` only on first hint open.
- Retry calls `generateTaskHint()` again after transport failure.
- Reopen after success does not call the API again.

Backend:

- Endpoint returns cached task hint without calling the LLM when one already exists for the session.
- Endpoint returns generated valid task hint for valid LLM JSON.
- Endpoint returns fallback when LLM returns malformed JSON.
- Endpoint returns fallback when LLM returns valid JSON with missing fields.
- Endpoint normalizes arrays to 3 example sentences and 4 vocabulary items.
- Endpoint returns 404 for missing session.

## Implementation Order

1. Add `TaskHint` types and `DialogueAPI.generateTaskHint()`.
2. Add real and mock API implementations.
3. Extract `TaskHintModal.vue` from the inline modal markup/styles.
4. Wire `DialogueChatView.vue` lazy-load state and button behavior.
5. Add backend endpoint, service function, and per-session task-hint storage/cache.
6. Add backend prompt, parse/validate/normalize/fallback logic.
7. Add focused frontend and backend tests.
