# 对话 Session 创建拆分 - 实现计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 把 `POST /session` 里的"建会话 + 生成开场白"拆成两个独立请求,前端每步独立 loading / 重试;全部重试 user-triggered,无自动重试。

**Architecture:** 后端新增 `POST /session/{id}/greeting`,幂等实现 + `SELECT ... FOR UPDATE` 行锁并发保护。前端 `useDialogueEngine` 拆 `initSession` → `attachSession` + `generateGreeting` + `retryGreeting`;`TopicDiscussionPreview` 的"开始对话"按钮 async 化,进入聊天页后自动触发 greeting;error-card 按 HTTP status 区分可恢复 / 不可恢复(404/409 换"返回重开")。

**Tech Stack:**
- 后端:FastAPI + SQLAlchemy async + MySQL (asyncmy) + pytest/pytest-asyncio
- 前端:Vue 3 + TypeScript + composables (无测试框架,验证靠 `pnpm type-check` + 手动联调)

**Spec:** `/Users/buoy/Development/gitrepo/PPT/doc/DialogueSessionSplitDesign.md`

---

## File Structure

### 后端 (`/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`)

| 路径 | 改动 |
|---|---|
| `app/service/speaking/dialogue_service.py` | `create_session()` 拆为 `create_session_only()` + `generate_greeting()` |
| `app/api/dialogue.py` | `/session` 响应去掉 `aiMessage`;新增 `POST /session/{session_id}/greeting` 路由 |
| `tests/service/speaking/test_dialogue_service_greeting.py` | 新建:service 层幂等、行锁、lookup 逻辑单测 |
| `tests/api/__init__.py` | 新建(空文件,包标记) |
| `tests/api/test_dialogue_greeting.py` | 新建:端点层 200/404/409 集成测试 |

### 前端 (`/Users/buoy/Development/gitrepo/PPT`)

| 路径 | 改动 |
|---|---|
| `src/types/englishSpeaking.ts` | `SessionInfo` 去掉 `aiMessage`;`PreviewChatMessage` 加 `unrecoverable?: boolean` |
| `src/views/Editor/EnglishSpeaking/services/llmService.ts` | 新增 `DialogueApiError` + `createDialogueApi()` 工厂;`DialogueAPI` 接口扩 `generateGreeting`;Real/Mock 都实现 |
| `src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts` | 拆 `initSession` → `attachSession` + `generateGreeting`(含 inflight 锁 + AbortController) + `retryGreeting`;`onUnmounted` 追加 abort |
| `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` | 新 `sessionInfo` prop + `restart` emit;`onMounted` 改为 `attachSession + generateGreeting`;error-card 按 `unrecoverable` 三路分支;`handleRestart` 改为 emit |
| `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue` | `startDialogue` async 化,持有 `sessionCreating`/`sessionError`/`preparedSession`;按钮 loading/error UI;监听 `@restart` |

---

## Task 1: 后端 - DialogueService 拆分与幂等实现

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/service/speaking/dialogue_service.py` (拆 `create_session` 方法)
- Test: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_greeting.py` (新建)

- [ ] **Step 1.1: 写 service 层测试**

新建 `tests/service/speaking/test_dialogue_service_greeting.py`:

```python
"""Tests for split session creation + greeting generation."""

from unittest.mock import AsyncMock, MagicMock

import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool

from app.models.dialogue import Base, DialogueMessage, DialogueSession
from app.service.speaking.dialogue_service import DialogueService


@pytest_asyncio.fixture
async def db_session():
    """In-memory SQLite for fast tests. Uses SQLAlchemy core types that work on both MySQL and SQLite."""
    engine = create_async_engine(
        "sqlite+aiosqlite:///:memory:",
        poolclass=StaticPool,
        connect_args={"check_same_thread": False},
    )
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
    async with SessionLocal() as session:
        yield session
    await engine.dispose()


def _build_service(llm_response: str = "Hi! What's your favorite animal?") -> DialogueService:
    llm = MagicMock()
    llm.chat = AsyncMock(return_value=llm_response)
    return DialogueService(
        asr=MagicMock(),
        llm=llm,
        assessor=MagicMock(),
        storage=MagicMock(),
    )


@pytest.mark.asyncio
async def test_create_session_only_returns_session_without_llm_call(db_session: AsyncSession):
    """create_session_only 只建会话记录,不调 LLM,响应不含 aiMessage。"""
    service = _build_service()
    result = await service.create_session_only(
        db=db_session,
        topic="My favorite animal",
        total_rounds=3,
    )

    assert "sessionId" in result
    assert "aiMessage" not in result
    assert result["totalRounds"] == 3
    assert result["currentRound"] == 1

    # DB 只有 session 无 message
    sessions = (await db_session.execute(select(DialogueSession))).scalars().all()
    assert len(sessions) == 1
    messages = (await db_session.execute(select(DialogueMessage))).scalars().all()
    assert len(messages) == 0

    # LLM 未被调用
    service.llm.chat.assert_not_called()


@pytest.mark.asyncio
async def test_generate_greeting_happy_path(db_session: AsyncSession):
    """对新建 session 首次生成开场白:调 LLM,落 round=1 AI 消息,返回内容。"""
    service = _build_service(llm_response="Hello! Ready to chat?")
    created = await service.create_session_only(
        db=db_session, topic="Hobbies", total_rounds=3,
    )
    session_uuid = created["sessionId"]

    result = await service.generate_greeting(db=db_session, session_uuid=session_uuid)

    assert result["aiMessage"] == "Hello! Ready to chat?"
    msgs = (await db_session.execute(
        select(DialogueMessage).where(DialogueMessage.role == "ai")
    )).scalars().all()
    assert len(msgs) == 1
    assert msgs[0].round == 1
    assert msgs[0].content == "Hello! Ready to chat?"
    service.llm.chat.assert_called_once()


@pytest.mark.asyncio
async def test_generate_greeting_idempotent_returns_existing(db_session: AsyncSession):
    """重复调 generate_greeting 不重复调 LLM,不重复落消息,返回相同内容。"""
    service = _build_service(llm_response="First greeting")
    created = await service.create_session_only(
        db=db_session, topic="Sports", total_rounds=3,
    )
    session_uuid = created["sessionId"]

    r1 = await service.generate_greeting(db=db_session, session_uuid=session_uuid)
    # 把 mock 改成会抛异常的,证明二次调用不会走到 LLM
    service.llm.chat = AsyncMock(side_effect=AssertionError("LLM should NOT be called on idempotent hit"))
    r2 = await service.generate_greeting(db=db_session, session_uuid=session_uuid)

    assert r1["aiMessage"] == "First greeting"
    assert r2["aiMessage"] == "First greeting"
    msgs = (await db_session.execute(
        select(DialogueMessage).where(DialogueMessage.role == "ai")
    )).scalars().all()
    assert len(msgs) == 1


@pytest.mark.asyncio
async def test_generate_greeting_session_not_found(db_session: AsyncSession):
    """不存在的 session → raise LookupError。"""
    service = _build_service()
    with pytest.raises(LookupError):
        await service.generate_greeting(
            db=db_session, session_uuid="00000000-0000-0000-0000-000000000000",
        )


@pytest.mark.asyncio
async def test_generate_greeting_session_not_active(db_session: AsyncSession):
    """completed session → raise ValueError('not active')。"""
    service = _build_service()
    created = await service.create_session_only(
        db=db_session, topic="X", total_rounds=3,
    )
    session_uuid = created["sessionId"]

    # 手动把 session 置为 completed
    session = (await db_session.execute(
        select(DialogueSession).where(DialogueSession.uuid == session_uuid)
    )).scalar_one()
    session.status = "completed"
    await db_session.commit()

    with pytest.raises(ValueError, match="not active"):
        await service.generate_greeting(db=db_session, session_uuid=session_uuid)
```

- [ ] **Step 1.2: 运行测试确认失败**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/service/speaking/test_dialogue_service_greeting.py -v
```

Expected: 5 个 test 全部 ERROR 或 FAIL(`AttributeError: 'DialogueService' object has no attribute 'create_session_only'` 等)。

- [ ] **Step 1.3: 实现 `create_session_only` 和 `generate_greeting`**

修改 `app/service/speaking/dialogue_service.py`。先看现有 `create_session`(约 44-97 行),**保留**原方法不要删(后面任务 2 才改 API 层);新增两个方法并让 `create_session` 委派给它们以便零破坏。

在 `class DialogueService` 里插入两个新方法(比如放在 `create_session` 之前,44 行上方):

```python
    async def create_session_only(
        self,
        db: AsyncSession,
        topic: str,
        total_rounds: int = 3,
        duration_seconds: int | None = None,
        role_config: dict | None = None,
        user_id: str | None = None,
    ) -> dict:
        """只建会话记录,不调 LLM。"""
        system_prompt = SYSTEM_PROMPT_TEMPLATE.format(topic=topic)
        expires_at = None
        if duration_seconds:
            expires_at = datetime.now() + timedelta(seconds=duration_seconds)

        session = DialogueSession(
            user_id=user_id,
            topic=topic,
            role_config=role_config,
            total_rounds=total_rounds,
            current_round=1,
            status="active",
            system_prompt=system_prompt,
            expires_at=expires_at,
        )
        db.add(session)
        await db.commit()
        await db.refresh(session)

        return {
            "sessionId": session.uuid,
            "totalRounds": total_rounds,
            "currentRound": 1,
            "expiresAt": expires_at.isoformat() if expires_at else None,
        }

    async def generate_greeting(
        self,
        db: AsyncSession,
        session_uuid: str,
    ) -> dict:
        """为已存在的 session 生成开场白。幂等:已有 round=1 AI 消息直接返回。

        Raises:
            LookupError: session 不存在
            ValueError: session 非 active
        """
        # 行锁 serialize 同一 session 的并发请求
        result = await db.execute(
            select(DialogueSession)
            .where(DialogueSession.uuid == session_uuid)
            .with_for_update()
        )
        session = result.scalar_one_or_none()
        if session is None:
            raise LookupError(f"Session not found: {session_uuid}")
        if session.status != "active":
            raise ValueError(f"Session is not active: status={session.status}")

        # 幂等:查 round=1 的 AI 消息
        existing = await db.execute(
            select(DialogueMessage)
            .where(DialogueMessage.session_id == session.id)
            .where(DialogueMessage.round == 1)
            .where(DialogueMessage.role == "ai")
        )
        existing_msg = existing.scalar_one_or_none()
        if existing_msg is not None:
            await db.commit()  # 释放行锁
            return {"aiMessage": existing_msg.content}

        # 生成开场白
        messages = [
            {"role": "system", "content": session.system_prompt},
            {"role": "user", "content": f"Start a conversation about: {session.topic}"},
        ]
        ai_greeting = await self.llm.chat(messages, model="")

        ai_msg = DialogueMessage(
            session_id=session.id,
            round=1,
            role="ai",
            content=ai_greeting,
        )
        db.add(ai_msg)
        await db.commit()

        return {"aiMessage": ai_greeting}
```

- [ ] **Step 1.4: 运行测试确认通过**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/service/speaking/test_dialogue_service_greeting.py -v
```

Expected: 全部 5 个 test PASS。

- [ ] **Step 1.5: 确认现有 dialogue_service 测试仍通过**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/service/speaking/ -v
```

Expected: 所有既有测试 + 新测试都 PASS。

- [ ] **Step 1.6: 提交**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
git add app/service/speaking/dialogue_service.py tests/service/speaking/test_dialogue_service_greeting.py
git commit -m "feat(speaking): split create_session into create_session_only + generate_greeting

New methods on DialogueService:
- create_session_only: only inserts session row, returns sessionId (no LLM)
- generate_greeting: idempotent greeting generation with SELECT ... FOR UPDATE
  row lock; returns existing round=1 AI message if already present

Old create_session() left untouched — API layer still uses it. Will be
removed in the next task when /session endpoint switches over."
```

---

## Task 2: 后端 - API 路由拆分

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/api/dialogue.py` (`/session` 改响应,新增 `/session/{id}/greeting` 路由)
- Test: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/test_dialogue_greeting.py` (新建)
- Create: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/__init__.py` (空文件)

- [ ] **Step 2.1: 创建 api 测试包目录**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
mkdir -p tests/api
touch tests/api/__init__.py
```

- [ ] **Step 2.2: 写 API 层集成测试(用 httpx.AsyncClient + ASGITransport)**

新建 `tests/api/test_dialogue_greeting.py`:

```python
"""End-to-end HTTP tests for POST /session and POST /session/{id}/greeting.

Uses httpx.AsyncClient + ASGITransport so everything runs on a single asyncio
event loop — no threading / TestClient contortions when we want to mutate DB
state between requests.
"""

from unittest.mock import AsyncMock, MagicMock

import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import update
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool

from app.api.dialogue import get_dialogue_service
from app.main import app
from app.models.database import get_db
from app.models.dialogue import Base, DialogueSession
from app.service.speaking.dialogue_service import DialogueService


@pytest_asyncio.fixture
async def test_env():
    """Bring up in-memory SQLite + dependency overrides + async HTTP client."""
    engine = create_async_engine(
        "sqlite+aiosqlite:///:memory:",
        poolclass=StaticPool,
        connect_args={"check_same_thread": False},
    )
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    SessionLocal = async_sessionmaker(engine, expire_on_commit=False)

    async def _override_db():
        async with SessionLocal() as s:
            yield s

    def _override_service():
        llm = MagicMock()
        llm.chat = AsyncMock(return_value="Hi! Ready to talk?")
        return DialogueService(
            asr=MagicMock(), llm=llm, assessor=MagicMock(), storage=MagicMock(),
        )

    app.dependency_overrides[get_db] = _override_db
    app.dependency_overrides[get_dialogue_service] = _override_service

    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        yield client, SessionLocal

    app.dependency_overrides.clear()
    await engine.dispose()


@pytest.mark.asyncio
async def test_post_session_does_not_include_ai_message(test_env):
    client, _ = test_env
    r = await client.post("/api/speaking/dialogue/session", json={
        "topic": "My favorite animal", "totalRounds": 3,
    })
    assert r.status_code == 200
    body = r.json()
    assert "sessionId" in body
    assert "aiMessage" not in body


@pytest.mark.asyncio
async def test_post_greeting_happy_path(test_env):
    client, _ = test_env
    r = await client.post("/api/speaking/dialogue/session", json={
        "topic": "Sports", "totalRounds": 3,
    })
    session_id = r.json()["sessionId"]

    g = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
    assert g.status_code == 200
    assert g.json() == {"aiMessage": "Hi! Ready to talk?"}


@pytest.mark.asyncio
async def test_post_greeting_idempotent(test_env):
    client, _ = test_env
    r = await client.post("/api/speaking/dialogue/session", json={
        "topic": "X", "totalRounds": 3,
    })
    session_id = r.json()["sessionId"]

    g1 = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
    g2 = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
    assert g1.status_code == 200
    assert g2.status_code == 200
    assert g1.json() == g2.json()


@pytest.mark.asyncio
async def test_post_greeting_session_not_found_returns_404(test_env):
    client, _ = test_env
    g = await client.post(
        "/api/speaking/dialogue/session/00000000-0000-0000-0000-000000000000/greeting"
    )
    assert g.status_code == 404


@pytest.mark.asyncio
async def test_post_greeting_non_active_session_returns_409(test_env):
    client, SessionLocal = test_env
    r = await client.post("/api/speaking/dialogue/session", json={
        "topic": "X", "totalRounds": 3,
    })
    session_id = r.json()["sessionId"]

    # 直接在共享 engine 上更新 session 状态为 completed
    async with SessionLocal() as s:
        await s.execute(
            update(DialogueSession)
            .where(DialogueSession.uuid == session_id)
            .values(status="completed")
        )
        await s.commit()

    g = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
    assert g.status_code == 409
```

- [ ] **Step 2.3: 运行测试确认失败**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/api/test_dialogue_greeting.py -v
```

Expected: 5 个 test 全部 FAIL(路由不存在 / 响应含 aiMessage 等)。

- [ ] **Step 2.4: 修改 `/session` 端点使用 `create_session_only`**

编辑 `app/api/dialogue.py`,找到 `@router.post("/session")`(约 51-67 行),替换 `service.create_session(...)` 为 `service.create_session_only(...)`(签名更干净,去掉了内部不用的 role_config):

```python
@router.post("/session")
async def create_session(
    req: CreateSessionRequest,
    db: AsyncSession = Depends(get_db),
    service: DialogueService = Depends(get_dialogue_service),
):
    """创建对话 session(不生成开场白)。开场白由 POST /session/{id}/greeting 触发。"""
    logger.info(f"Creating session: topic={req.topic}, rounds={req.totalRounds}")
    result = await service.create_session_only(
        db=db,
        topic=req.topic,
        total_rounds=req.totalRounds,
        duration_seconds=req.durationSeconds,
        user_id=req.userId,
    )
    logger.info(f"Session created: {result['sessionId']}")
    return result
```

- [ ] **Step 2.5: 新增 `/session/{session_id}/greeting` 路由**

在 `app/api/dialogue.py` 中,`/speak` 路由上方(约 70 行之前)插入:

```python
@router.post("/session/{session_id}/greeting")
async def generate_greeting(
    session_id: str,
    db: AsyncSession = Depends(get_db),
    service: DialogueService = Depends(get_dialogue_service),
):
    """为已存在的 session 生成开场白(幂等)。"""
    logger.info(f"Generating greeting: session={session_id}")
    try:
        result = await service.generate_greeting(db=db, session_uuid=session_id)
    except LookupError:
        raise HTTPException(status_code=404, detail="Session not found")
    except ValueError:
        raise HTTPException(status_code=409, detail="Session is not active")
    logger.info(f"Greeting ready: session={session_id}")
    return result
```

- [ ] **Step 2.6: 运行 API 测试确认通过**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/api/test_dialogue_greeting.py -v
```

Expected: 5 个 test 全部 PASS。

- [ ] **Step 2.7: 删除(或废弃)旧的 `create_session` 方法**

回到 `app/service/speaking/dialogue_service.py`,删除原 `create_session` 方法(约 44-97 行)—— 现在没有调用方了。

- [ ] **Step 2.8: 全量回归测试**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest -v
```

Expected: 所有测试 PASS;没有 `create_session` 相关测试残留报错。

- [ ] **Step 2.9: 提交**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
git add app/api/dialogue.py app/service/speaking/dialogue_service.py tests/api/
git commit -m "feat(speaking): split session endpoint - /session creates only, /session/{id}/greeting generates opening

Breaking change for frontend:
- POST /session response no longer contains aiMessage field
- New POST /session/{id}/greeting endpoint for the AI opening message
  - 200 + {aiMessage}: success or idempotent hit
  - 404: session not found
  - 409: session not active

Old DialogueService.create_session removed (replaced by create_session_only +
generate_greeting, see previous commit)."
```

---

## Task 3: 前端 - 类型与 API 层

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts` (`SessionInfo` 去 `aiMessage`;`PreviewChatMessage` 加 `unrecoverable`;`DialogueAPI` 扩 `generateGreeting`)
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts` (`DialogueApiError`, `createDialogueApi` 工厂,Real/Mock 都加 `generateGreeting`)

- [ ] **Step 3.1: 更新类型定义**

编辑 `/Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts`:

1. 找到 `PreviewChatMessage` 接口(约 201-227 行),在 `error?: string` 下方加:

```typescript
  /** true 表示这个错误无法重试(如 session 404/409),UI 应提示用户重开 */
  unrecoverable?: boolean
```

2. 找到 `SessionInfo` 接口(约 247-251 行),改为:

```typescript
// 对话会话信息 (createSession 返回)
export interface SessionInfo {
  sessionId: string
  totalRounds: number
  currentRound: number
  expiresAt: string | null
}

// 开场白生成结果 (generateGreeting 返回)
export interface GreetingInfo {
  aiMessage: string
}
```

3. 找到 `DialogueAPI` 接口(约 259-263 行),改为:

```typescript
// 对话 API 接口
export interface DialogueAPI {
  createSession(config: SessionConfig): Promise<SessionInfo>
  generateGreeting(sessionId: string, signal?: AbortSignal): Promise<GreetingInfo>
  speak(sessionId: string, audioBlob: Blob, signal: AbortSignal): AsyncGenerator<SSEEvent>
  getReport(sessionId: string): Promise<DialogueReport>
}
```

- [ ] **Step 3.2: 扩展 `llmService.ts`**

编辑 `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts`。

1. 在文件顶部 import 行下方加 `DialogueApiError` 和 `GreetingInfo` 引用(GreetingInfo 已在 types 里,引进来):

```typescript
import type {
  DialogueAPI,
  SSEEvent,
  SessionConfig,
  SessionInfo,
  GreetingInfo,
  DialogueReport,
  SentenceEvaluation,
} from '@/types/englishSpeaking'
```

2. 在 `const API_BASE = ...` 下方定义 error 类与工厂(约第 4 行后):

```typescript
export class DialogueApiError extends Error {
  status: number
  constructor(message: string, status: number) {
    super(message)
    this.status = status
    this.name = 'DialogueApiError'
  }
}

export function createDialogueApi(mode: 'preview' | 'real'): DialogueAPI {
  return mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()
}
```

3. 替换 `RealDialogueAPI.createSession`(约 137-151 行):

```typescript
  async createSession(config: SessionConfig): Promise<SessionInfo> {
    const res = await fetch(`${API_BASE}/session`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify({
        topic: config.topic,
        totalRounds: config.totalRounds,
        roleId: config.roleId,
      }),
    })
    if (!res.ok) {
      throw new DialogueApiError(`createSession failed: ${res.status}`, res.status)
    }
    const body = await res.json()
    return {
      sessionId: body.sessionId,
      totalRounds: body.totalRounds,
      currentRound: body.currentRound,
      expiresAt: body.expiresAt ?? null,
    }
  }
```

4. 在 `RealDialogueAPI` 里,`createSession` 方法之后新加:

```typescript
  async generateGreeting(sessionId: string, signal?: AbortSignal): Promise<GreetingInfo> {
    const res = await fetch(`${API_BASE}/session/${sessionId}/greeting`, {
      method: 'POST',
      credentials: 'include',
      signal,
    })
    if (!res.ok) {
      const text = await res.text().catch(() => '')
      throw new DialogueApiError(
        `greeting failed: ${res.status}${text ? ` (${text.slice(0, 100)})` : ''}`,
        res.status,
      )
    }
    return res.json()
  }
```

5. 替换 `MockDialogueAPI.createSession`(约 191-197 行):

```typescript
  async createSession(_config: SessionConfig): Promise<SessionInfo> {
    this.roundIndex = 0
    return {
      sessionId: 'mock-session-' + Date.now(),
      totalRounds: _config.totalRounds,
      currentRound: 1,
      expiresAt: null,
    }
  }

  async generateGreeting(_sessionId: string, _signal?: AbortSignal): Promise<GreetingInfo> {
    // 模拟 300ms 延迟
    await new Promise(r => setTimeout(r, 300))
    return { aiMessage: "Hi! What's your favorite animal?" }
  }
```

- [ ] **Step 3.3: 类型检查**

```bash
cd /Users/buoy/Development/gitrepo/PPT
pnpm type-check 2>&1 | tail -30
```

Expected: 没有 error;如果报 `useDialogueEngine.ts:39` 附近 `aiMessage` 访问错,留到 Task 4 里一起修(预期行为)。

如有其它地方引用了 `SessionInfo.aiMessage`,搜一遍:

```bash
grep -rn "\.aiMessage\b" /Users/buoy/Development/gitrepo/PPT/src
```

Expected:只剩 `useDialogueEngine.ts` 一处(本次任务不改它,Task 4 处理)。

- [ ] **Step 3.4: 提交**

```bash
cd /Users/buoy/Development/gitrepo/PPT
git add src/types/englishSpeaking.ts src/views/Editor/EnglishSpeaking/services/llmService.ts
git commit -m "feat(speaking): add generateGreeting to DialogueAPI + DialogueApiError type

- SessionInfo no longer carries aiMessage (moved to separate GreetingInfo)
- PreviewChatMessage has new optional unrecoverable flag
- DialogueApiError carries HTTP status so callers can distinguish 404/409
- createDialogueApi factory for shared API instantiation

useDialogueEngine still references old SessionInfo shape — fixed in next task."
```

---

## Task 4: 前端 - useDialogueEngine 拆分

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts` (拆 `initSession`,新增 `attachSession` / `generateGreeting` / `retryGreeting`,含 inflight 锁 + AbortController)

- [ ] **Step 4.1: 更新 imports**

编辑 `useDialogueEngine.ts`,把第 2 行 import 扩展(加入工厂和 error 类):

```typescript
import { MockDialogueAPI, RealDialogueAPI, createDialogueApi, DialogueApiError } from '../services/llmService'
```

保留现有 MockDialogueAPI / RealDialogueAPI 的 import 不强求,但用工厂就不需要它们了 —— 建议清掉改为:

```typescript
import { createDialogueApi, DialogueApiError } from '../services/llmService'
```

第 13 行把 `let api: DialogueAPI = mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()` 改为:

```typescript
  const api: DialogueAPI = createDialogueApi(mode)
```

- [ ] **Step 4.2: 新增 greeting 相关 state + 方法,并删除老 `initSession`**

删除原 `initSession` 函数(约 23-42 行),在同一位置插入:

```typescript
  // ==================== Session Attach ====================

  const greetingInflight = ref(false)
  let greetingAbortController: AbortController | null = null

  /** 仅附加已建好的 session 到 engine,不发任何网络请求。 */
  function attachSession(info: { sessionId: string; expiresAt?: string | null }) {
    sessionId.value = info.sessionId
    expiresAt.value = info.expiresAt ?? null
    if (info.expiresAt) startCountdown(info.expiresAt)
  }

  /** 触发开场白生成:push loading 占位 AI 消息 → 请求 /greeting → 成功填内容,失败标 error。 */
  async function generateGreeting() {
    if (!sessionId.value || greetingInflight.value) return
    greetingInflight.value = true
    greetingAbortController = new AbortController()

    const aiMsg = reactive<PreviewChatMessage>({
      id: crypto.randomUUID(),
      role: 'ai',
      content: '',
      timestamp: new Date(),
      status: 'loading',
    })
    messages.value.push(aiMsg)

    try {
      const { aiMessage } = await api.generateGreeting(sessionId.value, greetingAbortController.signal)
      aiMsg.content = aiMessage
      aiMsg.status = 'done'
      speakTTS(aiMessage)
    } catch (err: any) {
      if (err?.name === 'AbortError') return  // 组件卸载:不改 UI
      aiMsg.status = 'error'
      aiMsg.error = friendlyErrorMessage(err?.message)
      const status = err instanceof DialogueApiError ? err.status : undefined
      aiMsg.unrecoverable = status === 404 || status === 409
    } finally {
      greetingInflight.value = false
      greetingAbortController = null
    }
  }

  /** 重试失败的开场白:移除 error 消息,再走一遍 generateGreeting(同 sessionId)。 */
  async function retryGreeting() {
    if (greetingInflight.value) return
    const firstAi = messages.value.find(m => m.role === 'ai')
    if (firstAi?.status !== 'error' || firstAi.unrecoverable) return
    messages.value = messages.value.filter(m => m.id !== firstAi.id)
    await generateGreeting()
  }
```

- [ ] **Step 4.3: `onUnmounted` 追加 greeting abort**

找到 `onUnmounted`(约 391 行),把原本的:

```typescript
  onUnmounted(() => {
    abort()
    cancelTTS()
    stopCountdown()
  })
```

改为:

```typescript
  onUnmounted(() => {
    abort()
    greetingAbortController?.abort()
    cancelTTS()
    stopCountdown()
  })
```

- [ ] **Step 4.4: 更新 return 出去的接口**

找到返回对象(约 397 行),把 `initSession` 行替换为三个新方法:

```typescript
  return {
    messages,
    sessionId,
    currentRound,
    isComplete,
    isProcessing,
    canRecord,
    countdownSeconds,
    greetingInflight,   // 新增:UI 可用来 disable 重试按钮

    attachSession,      // 替代 initSession
    generateGreeting,
    retryGreeting,
    sendStudentMessage,
    beginStudentStream,
    streamFallback,
    retryMessage,
    regenerateAiMessage,
    getReport,
    abort,
    cancelTTS,
  }
```

- [ ] **Step 4.5: 类型检查**

```bash
cd /Users/buoy/Development/gitrepo/PPT
pnpm type-check 2>&1 | tail -40
```

Expected:在 `DialogueChatView.vue` 里会报 `engine.initSession is not a function` 之类的错(下一个任务修);`useDialogueEngine.ts` 本身应干净。

- [ ] **Step 4.6: 提交**

```bash
cd /Users/buoy/Development/gitrepo/PPT
git add src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts
git commit -m "refactor(speaking): split initSession into attachSession + generateGreeting + retryGreeting

- attachSession: sync method, only wires sessionId/expiresAt into engine refs
- generateGreeting: posts to /greeting, pushes loading AI placeholder, handles
  DialogueApiError status to flag 404/409 as unrecoverable
- retryGreeting: removes the failed AI message and re-runs generateGreeting
- greetingInflight ref guards concurrent requests
- onUnmounted aborts in-flight greeting fetch

Next task: wire DialogueChatView/TopicDiscussionPreview to the new API."
```

---

## Task 5: 前端 - DialogueChatView 接入新 API

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` (新 `sessionInfo` prop + `restart` emit;`onMounted` 改写;error-card 按钮分支;`handleRestart` 改 emit)

- [ ] **Step 5.1: 新增 prop 和 emit**

编辑 `DialogueChatView.vue`。找到 `interface Props`(约 491 行),在最后加一行:

```typescript
interface Props {
  topic?: string
  keywords?: string[]
  aiName?: string
  aiAvatar?: string
  totalRounds?: number
  mode?: 'preview' | 'real'
  showEnglishText?: boolean
  showChineseText?: boolean
  sessionInfo?: { sessionId: string; expiresAt: string | null } | null
}
```

`withDefaults` 调用(约 502 行)对应加 `sessionInfo: null`:

```typescript
const props = withDefaults(defineProps<Props>(), {
  topic: '我最喜欢的动物',
  keywords: () => ['animal', 'zoo', 'cute', 'favorite'],
  aiName: 'Tom',
  aiAvatar: '😊',
  totalRounds: 3,
  mode: 'preview',
  showEnglishText: true,
  showChineseText: false,
  sessionInfo: null,
})
```

找到 `const emit = defineEmits<...>`(约 513 行),扩展为:

```typescript
const emit = defineEmits<{
  complete: [report: DialogueReport | null]
  restart: []
}>()
```

- [ ] **Step 5.2: 替换 `onMounted` 逻辑**

找到 `onMounted`(约 950 行)。把内部 `engine.initSession(...)` 调用整体替换:

```typescript
onMounted(() => {
  if (props.sessionInfo) {
    engine.attachSession(props.sessionInfo)
    engine.generateGreeting()
  }
  // 无 sessionInfo 时聊天区保持空(父组件应当先创建 session 再挂载本组件)

  totalTimer = setInterval(() => {
    if (engine.countdownSeconds.value == null) totalSeconds.value += 1
  }, 1000)
})
```

- [ ] **Step 5.3: 添加 `retryAiMessage` 帮助函数**

在 `script setup` 里 `handleRetry` 附近(约 721 行之后)插入:

```typescript
function retryAiMessage(message: PreviewChatMessage) {
  const idx = engine.messages.value.indexOf(message)
  const hasPrevStudent = engine.messages.value.slice(0, idx).some(m => m.role === 'student')

  // 第一条 AI 消息(前面没有学生消息)= greeting
  if (!hasPrevStudent) {
    if (message.unrecoverable) {
      emit('restart')
      return
    }
    engine.retryGreeting()
    return
  }

  // 非首条:沿用原 regenerate
  engine.regenerateAiMessage(message.id)
}
```

- [ ] **Step 5.4: 替换模板里 AI error-card 的重试按钮**

找到模板里 AI 消息的 error-card 块(约 85-88 行):

```vue
<!-- AI 错误 -->
<div v-if="message.status === 'error'" class="error-card">
  <span class="error-text">{{ message.error || '生成失败' }}</span>
  <button class="retry-btn" @click="engine.regenerateAiMessage(message.id)">重新生成</button>
</div>
```

替换为:

```vue
<!-- AI 错误 -->
<div v-if="message.status === 'error'" class="error-card">
  <span class="error-text">{{ message.error || '生成失败' }}</span>
  <button
    class="retry-btn"
    :disabled="engine.greetingInflight.value"
    @click="retryAiMessage(message)"
  >{{ message.unrecoverable ? '返回重开' : '重新生成' }}</button>
</div>
```

- [ ] **Step 5.5: 改写 `handleRestart`**

找到 `handleRestart`(约 817 行),替换为:

```typescript
function handleRestart() {
  showExitConfirm.value = false
  engine.abort()
  engine.cancelTTS()
  emit('restart')
}
```

(父组件 `TopicDiscussionPreview` 会在收到 emit 后清状态并回 ready 页,session 重建由用户再点"开始对话"触发。)

- [ ] **Step 5.6: 类型检查**

```bash
cd /Users/buoy/Development/gitrepo/PPT
pnpm type-check 2>&1 | tail -30
```

Expected:DialogueChatView 内部应干净;在 `TopicDiscussionPreview.vue` 处可能还报 prop/emit 未使用的警告(下一个任务修)。

- [ ] **Step 5.7: 提交**

```bash
cd /Users/buoy/Development/gitrepo/PPT
git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
git commit -m "feat(speaking): wire DialogueChatView to new sessionInfo prop + greeting flow

- New sessionInfo prop (from parent) replaces engine.initSession auto-trigger
- On mount: attachSession + generateGreeting → user sees typing bubble immediately
- Error retry button: three branches (retryGreeting / restart / regenerateAi)
  driven by message.unrecoverable flag and presence of prior student message
- handleRestart emits 'restart' instead of recreating session internally;
  parent returns to ready stage"
```

---

## Task 6: 前端 - TopicDiscussionPreview 异步启动

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue` (async `startDialogue`,loading/error UI,监听 `@restart`)

- [ ] **Step 6.1: 更新 imports**

编辑 `TopicDiscussionPreview.vue`,在 `import` 区域(约 63-69 行)加:

```typescript
import { createDialogueApi, DialogueApiError } from '../services/llmService'
```

- [ ] **Step 6.2: 新增 session 创建状态 ref**

在 `dialogueState` ref 定义(约 89 行)之后加:

```typescript
const sessionCreating = ref(false)
const sessionError = ref<string | null>(null)
const preparedSession = ref<{ sessionId: string; expiresAt: string | null } | null>(null)
```

- [ ] **Step 6.3: 改写 `startDialogue`**

找到 `startDialogue` 函数(约 198-200 行):

```typescript
function startDialogue() {
  dialogueState.value = 'chatting'
}
```

替换为:

```typescript
async function startDialogue() {
  if (sessionCreating.value) return
  sessionCreating.value = true
  sessionError.value = null
  try {
    const api = createDialogueApi(props.mode)
    const info = await api.createSession({
      topic: speakingStore.config.topic || props.topic,
      totalRounds: speakingStore.config.practice.rounds || props.totalRounds,
      roleId: 'tom',
      vocabulary: speakingStore.config.learningGoals.vocabulary,
    })
    preparedSession.value = {
      sessionId: info.sessionId,
      expiresAt: info.expiresAt,
    }
    dialogueState.value = 'chatting'
  } catch (err: unknown) {
    if (err instanceof DialogueApiError) {
      sessionError.value = `创建会话失败(${err.status}),请重试`
    } else {
      sessionError.value = '创建会话失败,请重试'
    }
  } finally {
    sessionCreating.value = false
  }
}
```

- [ ] **Step 6.4: 扩展 `resetPreview` 清 `preparedSession`**

找到 `resetPreview`(约 207 行):

```typescript
function resetPreview() {
  dialogueState.value = 'ready'
  realEvaluation.value = null
}
```

替换为:

```typescript
function resetPreview() {
  dialogueState.value = 'ready'
  realEvaluation.value = null
  preparedSession.value = null
  sessionError.value = null
  sessionCreating.value = false
}
```

- [ ] **Step 6.5: 模板里传 sessionInfo + 监听 restart**

找到模板中 `<DialogueChatView>` 标签(约 33-42 行):

```vue
<DialogueChatView
  v-else-if="dialogueState === 'chatting'"
  :topic="speakingStore.config.topic || topic"
  :keywords="speakingStore.config.learningGoals.vocabulary.length ? speakingStore.config.learningGoals.vocabulary : keywords"
  :ai-name="mockRole.name"
  :ai-avatar="mockRole.avatar"
  :total-rounds="speakingStore.config.practice.rounds || totalRounds"
  :mode="mode"
  @complete="handleDialogueComplete"
/>
```

扩展为:

```vue
<DialogueChatView
  v-else-if="dialogueState === 'chatting'"
  :topic="speakingStore.config.topic || topic"
  :keywords="speakingStore.config.learningGoals.vocabulary.length ? speakingStore.config.learningGoals.vocabulary : keywords"
  :ai-name="mockRole.name"
  :ai-avatar="mockRole.avatar"
  :total-rounds="speakingStore.config.practice.rounds || totalRounds"
  :mode="mode"
  :session-info="preparedSession"
  @complete="handleDialogueComplete"
  @restart="resetPreview"
/>
```

- [ ] **Step 6.6: 改按钮 UI + 加错误提示**

找到 ready 阶段的 `start-btn`(约 18-29 行):

```vue
<div class="ready-footer">
  <button class="start-btn" @click="startDialogue">
    <svg ...><!-- 麦克风图标 --></svg>
    开始对话
  </button>
</div>
```

替换为:

```vue
<div class="ready-footer">
  <button class="start-btn" :disabled="sessionCreating" @click="startDialogue">
    <svg v-if="!sessionCreating" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
      stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
      <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
      <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
      <line x1="12" y1="19" x2="12" y2="23" />
      <line x1="8" y1="23" x2="16" y2="23" />
    </svg>
    <span v-else class="start-btn-spinner" />
    {{ sessionCreating ? '创建中…' : '开始对话' }}
  </button>
  <p v-if="sessionError" class="session-error-text">{{ sessionError }}</p>
</div>
```

- [ ] **Step 6.7: 样式补丁**

在 `<style lang="scss" scoped>` 区(找 `.start-btn` 样式附近,约第 312 行)下方追加:

```scss
.start-btn:disabled {
  opacity: 0.6;
  cursor: wait;
  background: #fb923c;
}
.start-btn-spinner {
  width: 14px;
  height: 14px;
  border: 2px solid rgba(255, 255, 255, 0.4);
  border-top-color: #fff;
  border-radius: 50%;
  animation: start-btn-spin 0.8s linear infinite;
  display: inline-block;
}
@keyframes start-btn-spin {
  to { transform: rotate(360deg); }
}
.session-error-text {
  margin: 8px 0 0;
  font-size: 12px;
  color: #dc2626;
  text-align: center;
}
```

- [ ] **Step 6.8: 类型检查**

```bash
cd /Users/buoy/Development/gitrepo/PPT
pnpm type-check 2>&1 | tail -30
```

Expected: 无 error,无 warning。

- [ ] **Step 6.9: 提交**

```bash
cd /Users/buoy/Development/gitrepo/PPT
git add src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue
git commit -m "feat(speaking): TopicDiscussionPreview creates session on start button click

- 'Start dialogue' button now triggers POST /session before navigating
- Button shows loading spinner while creating; disabled during in-flight
- On failure: error text below button, button re-enabled for retry
- On success: passes sessionInfo prop to DialogueChatView so it can attach
  and trigger greeting immediately
- Listens to child 'restart' emit to return to ready stage"
```

---

## Task 7: 联调与验收

- [ ] **Step 7.1: 启动后端**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run uvicorn app.main:app --reload --port 8000
```

Expected: uvicorn started,无异常日志。

- [ ] **Step 7.2: 启动前端**

```bash
cd /Users/buoy/Development/gitrepo/PPT
pnpm dev
```

- [ ] **Step 7.3: 手动测 - 正常流**

1. 打开浏览器到 PPT,编辑模式进入包含英语口语元素的 PPT
2. 切到预览 → ready 页 → 点击"开始对话"
3. 按钮短暂变"创建中…" + spinner
4. 进入聊天页 → 立即看到 typing-bubble
5. ~2 秒后 greeting 文字出现 + TTS 朗读

打开浏览器 DevTools Network 面板验证请求顺序:
- `POST /api/speaking/dialogue/session` → 200,响应体 **不含** `aiMessage`
- `POST /api/speaking/dialogue/session/{id}/greeting` → 200,响应体 `{"aiMessage":"..."}`

- [ ] **Step 7.4: 手动测 - session 创建失败重试**

1. 临时停掉后端(Ctrl+C)
2. 回到 ready 页(可刷新)→ 点"开始对话"
3. 验证:按钮短暂 loading,错误文案显示在按钮下方,按钮恢复可点
4. 启动后端,再次点击"开始对话"
5. 验证:成功进入聊天页

- [ ] **Step 7.5: 手动测 - greeting 500 失败重试**

在 `OneHubLLM.chat` 里临时 raise Exception(如 `raise RuntimeError("forced fail")`),重启后端。

1. ready 页点"开始对话"→ 进入聊天页 → 看到 typing-bubble
2. 几秒后 typing-bubble 变成 error-card,按钮文案"重新生成"
3. 恢复 `OneHubLLM.chat`,重启后端
4. 点击"重新生成"→ 再次 loading → greeting 成功

验证 Network 面板:第二次 `/greeting` 请求命中**同一个** sessionId。

- [ ] **Step 7.6: 手动测 - greeting 404 不可恢复**

1. 进入聊天页拿到 sessionId(Network 里看)
2. 后端通过 SQL 删除该 session `DELETE FROM dialogue_session WHERE uuid = '...'`
3. 模拟失败场景:打开新标签,手动 POST 一次 `/session/{删除的id}/greeting`(curl 即可),看是否返 404
4. 回到聊天页 → 刷新 → 再次进入对话页 → 此时 attachSession 会拿新 sessionId,所以没法直接复现 404
5. 更好的重现:在 `RealDialogueAPI.generateGreeting` 临时返固定的 404 mock 错误,点击"重新生成"按钮,观察文案变成"返回重开",点击后应回到 ready 页

或更简单:在 DevTools Console 手动操控消息状态:

```js
// 找到第一条 AI 消息,把 unrecoverable 改 true,status 改 error
// (仅用于 smoke 检查 UI 分支)
```

- [ ] **Step 7.7: 手动测 - 幂等(并发模拟)**

在 Network 面板打开 Throttling → Slow 3G。

1. 走正常流,聊天页 typing-bubble 期间迅速(2 秒内)点击退出弹窗→"重新开始"
2. resetPreview 回 ready → 再立刻点"开始对话"进新 session
3. 观察老 session 的 greeting 请求被 abort(Network 面板 cancelled)

验证 DB:新老两个 session 各自 round=1 AI 只有一条。

或用 curl 并发验证:

```bash
# 建个 session
SID=$(curl -s -X POST http://localhost:8000/api/speaking/dialogue/session \
  -H 'Content-Type: application/json' \
  -d '{"topic":"test","totalRounds":3}' | jq -r .sessionId)
echo "session=$SID"

# 并发两次 greeting
(curl -s -X POST http://localhost:8000/api/speaking/dialogue/session/$SID/greeting &
 curl -s -X POST http://localhost:8000/api/speaking/dialogue/session/$SID/greeting &
 wait) | tee /tmp/greetings.log

# 应当两个响应 aiMessage 完全相同
cat /tmp/greetings.log

# DB 只有一条 round=1 AI 消息
mysql -u root speaking -e \
  "SELECT COUNT(*) FROM dialogue_message m JOIN dialogue_session s ON m.session_id=s.id \
   WHERE s.uuid='$SID' AND m.role='ai' AND m.round=1"
# → 1
```

- [ ] **Step 7.8: 手动测 - unmount 时 abort**

1. ready 页点"开始对话"→ 进入聊天页
2. greeting 请求 in-flight 时(Slow 3G 下有时间),点退出弹窗→"重新开始"
3. Network 面板:原 greeting 请求 status 变 `(canceled)`

- [ ] **Step 7.9: 回归测 - 原有对话流程**

1. 完整走完一次 3 轮对话
2. 验证:录音 → STT → AI 回复 → 评分的流程全部正常
3. 中间某一轮 AI 回复若失败,error-card 上按钮仍是"重新生成"(非"返回重开"),点击后 `regenerateAiMessage` 照旧工作
4. 报告页按既有行为出现

- [ ] **Step 7.10: 全量回归后端单测**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest -v
```

Expected: 所有 test PASS。

- [ ] **Step 7.11: 验收清单核对**

对着 `/Users/buoy/Development/gitrepo/PPT/doc/DialogueSessionSplitDesign.md` §6 的 11 条验收标准逐条打勾。

---

## 附录:回滚策略

如果上线后 LLM 失败率暴增导致用户大量走"重新生成"路径挤压 LLM 配额,可以通过以下方式紧急回滚:

1. 后端回滚到 Task 2 之前的提交(保留 Task 1 的 service 层 `create_session_only` / `generate_greeting`,但恢复 `/session` 的旧 bundled 行为)—— 因为类型和前端已经不兼容,实际回滚路径是**回滚前端+后端两边**。
2. 所有修改都在 feature branch(`feat/english-speaking`)上,反转 commits 用 `git revert <hash>` 即可。

没有 DB schema 变更,回滚不涉及数据清理。
