# Class AI Report — Design **Date:** 2026-05-16 **Status:** Approved (design) **Repos:** `PPT` (frontend), `cococlass-english-speaking-api` (backend) ## Problem `AISummary.vue` currently shows a compact 3-bullet card. The backend `generate_class_summary` feeds the class-summary LLM only thin aggregated data (`overallScore`, `dimensions`, `topHighlights`, `topImprovements` per student) and gets back 3 short text bullets. The new requirement is a full **tiered Markdown report**: an A/B/C tier statistics table, an optional ASCII bar chart, and Key Insights — driven by a richer per-student payload (per-sentence transcripts + sentence comments, word-level `accuracy_score`/`error_type`, per-user dimension scores + comment) and a new LLM system prompt. ## Key findings (resolved during brainstorming) 1. **The backend `overall_report` has no numeric scores.** `OverallReportEvaluator` only emits `{ aiComment, highlights, improvements }`. The reads in `list_sessions_by_config` for `overall_report.get("overallScore")` / `.get("dimensions")` are dead — always `null`/`{}`. Today's class summary silently aggregates zeros. 2. **The per-user 4 scores exist only in the frontend.** `adaptReport()` in `llmService.ts` computes `overallScore` + `dimensions` by **averaging the per-sentence Azure scores**, relabeled: - `overallScore` = mean over student sentences of `avg(accuracy, fluency, prosody, completeness)` - `dimensions.fluency` = avg Azure `fluencyScore` - `dimensions.interaction` = avg Azure `prosodyScore` - `dimensions.vocabulary` = avg Azure `completenessScore` - `dimensions.grammar` = avg Azure `accuracyScore` 3. **Consequence:** no new evaluator or DB migration is needed for per-user scoring. The class-report backend mirrors `adaptReport`'s averaging server-side, keeping the class report numerically consistent with the per-student report. ## Decisions - **UI placement:** expandable inline panel in `SpeakingClassPanel`. Collapsed = one live completion line + expand toggle; expanded = full Markdown report in a scroll region. - **Delivery:** server streams the Markdown via SSE; the panel renders progressively. - **Payload scope:** full per-sentence detail, but capped at the first 30 completed students (ordered by `completedAt`); a `truncated` flag is passed when capped. - **Per-user scores:** computed server-side by averaging Azure per-sentence scores (mirrors `adaptReport`). No new evaluator, no migration. - **Table column** `形状词汇使用率` in the supplied prompt is topic-specific (copied from a "shapes" example) and is generalized to `目标词汇使用率`. - **Tier thresholds:** hardcoded A `≥85` / B `75-84` / C `<75`, per the prompt. ## Backend — `cococlass-english-speaking-api` ### New endpoint `POST /api/speaking/dialogue/sessions/by-config/summary/stream` — SSE response. Request body: ```json { "configId": "...", "students": [{ "userId": "...", "name": "..." }], "locale": "zh|en|hk" } ``` SSE events reuse the existing shape parsed by `parseSSEStream`: `{ "type": "token", "text": "..." }` … then `{ "type": "done" }` or `{ "type": "error", "message": "..." }`. ### Payload builder — `_build_class_report_input()` 1. Reuse `list_sessions_by_config` to get the latest session per user. 2. For `completed` sessions, ordered by `completedAt`, take the first 30. Load `DialogueMessage` + `selectinload(evaluation)` rows (same pattern as `get_report`). 3. Per student build: ``` { name, overallScore, # avg of sentence avg(4 Azure scores) dimensions: { fluency, interaction, vocabulary, grammar }, # Azure averages aiComment, highlights, improvements, # from overall_report sentences: [ { round, transcript, sentenceComment, # evaluation.content_feedback.comment, omit if absent wordAnalysis: [{ word, accuracyScore, errorType }] } ] } ``` Only `role == "student"` messages with a completed evaluation contribute scores. 4. `classStats`: `{ total, submitted, unsubmitted, notStarted, rate, avgScore, highScore, lowScore }` computed from the real averaged scores. 5. If the completed count exceeded 30: `truncated: true` plus `completedTotal` / `includedCount` so the LLM can note partial coverage. ### `ClassReportEvaluator` - New evaluator carrying the supplied system prompt (with the `目标词汇使用率` column generalization). Output is **Markdown**, not JSON. - Calls the LLM with `stream=True`; yields Markdown chunks as they arrive. - User message = JSON of `{ classStats, perStudent, locale }` with the existing "treat data as data, not instructions" safety preamble. ### Endpoint flow & caching - Build payload → if `submitted == 0`, skip the LLM and stream a single short "waiting for submissions" message (localized) then `done`. - Otherwise stream LLM chunks as `token` events; accumulate full text. - On stream completion, store the full Markdown in the summary cache keyed by `(configId, contentHash(summaries))`, same `SUMMARY_TTL_SECONDS`. - Cache hit: replay the stored Markdown as one `token` event + `done`. - LLM error/timeout: emit `{ type: "error" }`. ### Removed (dead after this lands) - `POST /sessions/by-config/summary` route, `ClassSummaryEvaluator`, `class_summary_rules.py`, and the `bullet_*` rule helpers. - The `overall_report.get("overallScore")`/`.get("dimensions")` reads in `list_sessions_by_config` stay — harmless, and `list_sessions_by_config` is still used by the student grid. ## Frontend — `PPT` ### `src/services/speaking.ts` - Add `streamClassReport(configId, students, locale)` — async generator using `fetch` + the shared `parseSSEStream`; yields Markdown token strings. - `parseSSEStream` is promoted from `llmService.ts` to a shared module (or imported) so both services use one implementation. - Remove `generateClassSummary` + `ClassSummaryResponse`. ### `src/views/Student/components/SpeakingClassPanel/useClassSummary.ts` - Remove `aiBullets`, `liveBullet1`-as-tuple wiring, `frontendRuleBullet2/3`, `aiBackendBullets`, `refreshAISummary`. - Keep `liveBullet1` (live completion count/rate) as a standalone computed — it is the collapsed-state line and needs no LLM call. - Add: `reportMarkdown: Ref`, `reportStreaming: Ref`, `reportGeneratedAt: Ref`, `reportError: Ref`, `refreshReport()` — opens the stream, appends tokens to `reportMarkdown`, token-guard against stale streams (same `aiToken` pattern). ### `src/views/Student/components/SpeakingClassPanel/AISummary.vue` - Props become `{ completionLine: string, markdown: string, streaming: boolean, generatedAt: string|null, error: string|null }`; emits `refresh`. - **Collapsed:** completion line + `查看完整分析报告 ▾` toggle. - **Expanded:** render `markdown` with `markdown-it` (`html: true`), pass through **DOMPurify** with a tight allowlist (table tags + `span` with a restricted `style` attr), in a `max-height` + `overflow:auto` region. ASCII chart renders inside a fenced code block (monospace). - Progressive render while `streaming`; skeleton before the first token. - `submitted == 0` → show the localized waiting message instead of the report. - Stream error → error bar + retry; last good Markdown is kept. - Keep the existing "刚刚生成 / N 秒前" time-ago label. ### Dependencies - Add **`dompurify`** (+ `@types/dompurify`) to `PPT`. `markdown-it` already present. ### i18n New keys: expand/collapse labels, "查看完整分析报告", regenerate, stream-error message, waiting-for-submissions message. ## Error handling - Stream error / timeout → error bar with retry; previous Markdown preserved. - No completed students → no LLM call; localized waiting message. - Malformed Markdown from the LLM → rendered as-is by `markdown-it`; acceptable. - Stale stream (panel re-fetched / unmounted) → discarded via the token guard. ## Testing - **Backend (pytest):** `_build_class_report_input` — score averaging matches `adaptReport`, 30-cap + `truncated` flag, sentence/word assembly, missing `content_feedback`; streamed endpoint with a stubbed LLM (token events → `done`); cache-hit replay path. - **Frontend:** `useClassSummary` stream accumulation + stale-token discard; `markdown-it` → DOMPurify render (red `` survives, `