Jelajahi Sumber

fix(reading): align balanced quiz options with printable answers and explanations

彭峰 5 jam lalu
induk
melakukan
194d8e95db

+ 14 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.spec.ts

@@ -119,4 +119,18 @@ describe('reading question persistence', () => {
   expect(ai.json).toHaveBeenCalledTimes(3);
   expect(cloud.create).not.toHaveBeenCalled();
  });
+ it('does not teach the generator to put every correct answer first',async()=>{
+  const {service,ai}=setup(); await service.createQuestions(article,()=>{});
+  const example=JSON.parse(ai.json.calls.first().args[1]);
+  expect(new Set(example.questions.map((q:any)=>q.options.findIndex((o:any)=>o.check))).size).toBe(4);
+ });
+ it('retries generated explanations tied to option letters before saving',async()=>{
+  const {service,cloud,ai}=setup(); let calls=0;
+  ai.json.and.callFake(async()=>{const qs=generated(5);if(calls++===0)qs[0].answer='正确选项为A。';return {questions:qs};});
+  const result=await service.createQuestions(article,()=>{});
+  expect(ai.json).toHaveBeenCalledTimes(2);
+  expect(result[0].answer).toBe('依据原文');
+  expect(cloud.create).toHaveBeenCalledTimes(5);
+ });
+
 });

+ 8 - 3
projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.ts

@@ -3,6 +3,7 @@ import { Subscription } from 'rxjs';
 import Mustache from 'mustache';
 import { FmodeAiService } from './fmode-ai.service';
 import { ReadingCloudService } from './reading-cloud.service';
+import { hasPositionDependentReadingContent, hasReadingOptionReferences } from './reading-presentation';
 import { READING_PROMPT_FALLBACKS } from './reading-prompts';
 import {
   ReadingArticle, ReadingHistoryEntry, ReadingLog, ReadingOption, ReadingProgress,
@@ -149,12 +150,12 @@ export class EnglishReadingService {
     });
     const count = missingIndexes.length;
     const previousTitles = existing.map((question) => question.title).filter(Boolean);
-    const basePrompt = `${prompt}\n最终 questions 数组必须恰好包含 ${count} 道不同的单选题,每题恰好四个不同选项且只有一个 check 为 true。${previousTitles.length ? `不要重复已有题目:${previousTitles.join(';')}` : ''}`;
+    const basePrompt = `${prompt}\n最终 questions 数组必须恰好包含 ${count} 道不同的单选题,每题恰好四个不同选项且只有一个 check 为 true。正确选项的位置应分散。题干、选项和解析必须独立于选项顺序:用选项内容解释,不得引用 A/B/C/D 字母、第一项或以上各项等位置。${previousTitles.length ? `不要重复已有题目:${previousTitles.join(';')}` : ''}`;
     const schema = JSON.stringify({
       count, stage: params.stage,
       questions: Array.from({ length: count }, (_, index) => ({
         title: `英文题目${index + 1}`, type: '题型', difficulty: 'easy|normal|difficult', wordList: [],
-        options: Array.from({ length: 4 }, (_, optionIndex) => ({ check: optionIndex === 0, value: `英文选项${optionIndex + 1}`, answer: '中文解析' })),
+        options: Array.from({ length: 4 }, (_, optionIndex) => ({ check: optionIndex === missingIndexes[index] % 4, value: `英文选项${optionIndex + 1}`, answer: '中文解析' })),
         answer: '中文题目解析',
       })),
     });
@@ -170,6 +171,10 @@ export class EnglishReadingService {
         const titles = new Set(previousTitles.map((title) => title.trim().toLowerCase()));
         if (Array.isArray(candidate) && candidate.length === count && candidate.every((item) => {
           if (!isValidReadingQuestion(item)) return false;
+          const question = item as GeneratedQuestion;
+          if (hasPositionDependentReadingContent(item.title)
+            || item.options.some(option => hasPositionDependentReadingContent(option.value) || hasReadingOptionReferences(option.answer || ''))
+            || hasReadingOptionReferences(question.answer || '')) return false;
           const title = item.title.trim().toLowerCase();
           if (titles.has(title)) return false;
           titles.add(title);
@@ -178,7 +183,7 @@ export class EnglishReadingService {
           list = candidate as GeneratedQuestion[];
           break;
         }
-        failure = `题目数量应为 ${count},且每题需要四个不同选项和唯一正确答案`;
+        failure = `题目数量应为 ${count},且每题需要四个不同选项和唯一正确答案,题干、选项及解析不得依赖选项字母或位置`;
       } catch (error) {
         failure = error instanceof Error ? error.message : '题目生成服务失败';
       }

+ 27 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.spec.ts

@@ -224,4 +224,31 @@ describe('ReadingExerciseComponent', () => {
     expect(reading.createArticle).toHaveBeenCalledTimes(1);
   });
 
+  it('keeps the same question presentation across reloads and preserves historical content answers', async () => {
+    component.params.sid = article.objectId;
+    reading.latestLog.and.resolveTo(savedLog);
+    await component.load();
+    const first = JSON.stringify(component.questions());
+    await component.load();
+    expect(JSON.stringify(component.questions())).toBe(first);
+    expect(component.score()).toBe(1);
+    const firstQuestion = component.questions().findIndex(q => q.objectId === 'question-1');
+    expect(component.answers[firstQuestion]).toBe('A');
+  });
+  it('carries the new retry version into printing and keeps reload consistent', async () => {
+    component.params.sid = article.objectId;
+    await component.load();
+    const first = JSON.stringify(component.questions());
+    component.submitted.set(true);
+    await component.reset();
+    const next = JSON.stringify(component.questions());
+    expect(next).not.toBe(first);
+    await component.print();
+    const params = router.navigate.calls.mostRecent().args[1]?.queryParams;
+    expect(params?.['presentationVersion']).toBeTruthy();
+    await component.load();
+    expect(JSON.stringify(component.questions())).toBe(next);
+    expect(component.answers.every(a => a === '')).toBeTrue();
+  });
+
 });

+ 32 - 14
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.ts

@@ -7,7 +7,9 @@ import { SessionService } from '../../core/session.service';
 import { EnglishReadingService } from './english-reading.service';
 import { ReadingCloudService } from './reading-cloud.service';
 import { ReadingArticle, ReadingHistoryEntry, ReadingProgress, ReadingQuestion, ReadingRouteParams } from './reading.models';
-import { highlightWords, isCompleteReadingQuestionSet, normalizeReadingParams, normalizeReadingReturnUrl, readingStudentName, shuffle } from './reading.utils';
+import { highlightWords, isCompleteReadingQuestionSet, normalizeReadingParams, normalizeReadingReturnUrl, readingStudentName, normalizeReadingPresentationVersion } from './reading.utils';
+
+import { presentReadingQuestions } from './reading-presentation';
 
 interface ReadingDialog {
   title: string;
@@ -49,6 +51,7 @@ export class ReadingExerciseComponent implements OnInit {
   readonly loadingTip = signal('');
   answers: string[] = [];
   params!: ReadingRouteParams;
+  private sourceQuestions: ReadingQuestion[] = [];
   private retryCount = 0;
   private readonly maxRetry = 5;
   private historyPageIndex = 0;
@@ -144,14 +147,26 @@ export class ReadingExerciseComponent implements OnInit {
     await this.submit();
   }
 
-  reset(): void {
-    if (this.saving() || this.legacyIncomplete()) return;
-    this.questions.set(shuffle(this.questions()).map((question) => ({ ...question, options: shuffle(question.options) })));
-    this.answers = new Array(this.questions().length).fill('');
-    this.score.set(0);
-    this.submitted.set(false);
-    this.saveFailed.set(false);
-    this.dialog.set(null);
+  async reset(): Promise<void> {
+    if (this.saving() || this.legacyIncomplete() || !this.article()) return;
+    const presentationVersion = `v1-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
+    const params = { ...this.params, presentationVersion };
+    this.saving.set(true);
+    try {
+      const questions = presentReadingQuestions(this.sourceQuestions, `${this.article()!.objectId}:${presentationVersion}`);
+      const navigated = await this.router.navigate(['/words/reading'], {
+        queryParams: { ...params, wordList: params.wordList.join(',') }, replaceUrl: true,
+      });
+      if (!navigated) return;
+      this.params = params;
+      this.questions.set(questions);
+      this.answers = new Array(questions.length).fill('');
+      this.score.set(0);
+      this.submitted.set(false);
+      this.saveFailed.set(false);
+      this.dialog.set(null);
+    } catch (error) { this.showError(error); }
+    finally { this.saving.set(false); }
   }
 
   async print(): Promise<void> {
@@ -163,17 +178,17 @@ export class ReadingExerciseComponent implements OnInit {
 
   async createNewArticle(): Promise<void> {
     if (this.legacyIncomplete()) {
-      const params = { ...this.params, sid: undefined, renew: true, wordList: this.params.wordList.join(','), t: Date.now() };
+      const params = { ...this.params, presentationVersion: 'v1', sid: undefined, renew: true, wordList: this.params.wordList.join(','), t: Date.now() };
       const navigated = await this.router.navigate(['/words/reading'], { queryParams: params, replaceUrl: true });
       if (navigated) {
-        this.params = { ...this.params, sid: undefined, renew: true };
+        this.params = { ...this.params, presentationVersion: 'v1', sid: undefined, renew: true };
         await this.load();
       }
       return;
     }
     if (!this.remainingArticles()) return;
     await this.router.navigate(['/words/go-reading'], {
-      queryParams: { ...this.params, wordList: this.params.wordList.join(','), type: '随机', theme: '随机', renew: true, t: Date.now() },
+      queryParams: { ...this.params, presentationVersion: 'v1', wordList: this.params.wordList.join(','), type: '随机', theme: '随机', renew: true, t: Date.now() },
     });
   }
 
@@ -224,7 +239,7 @@ export class ReadingExerciseComponent implements OnInit {
       replaceUrl: true,
     });
     if (!navigated) return;
-    this.params = { ...this.params, ...values, sid: entry.article.objectId, renew:false };
+    this.params = { ...this.params, ...values, sid: entry.article.objectId, renew:false, presentationVersion: 'v1' };
     await this.load();
   }
 
@@ -249,6 +264,7 @@ export class ReadingExerciseComponent implements OnInit {
     this.error.set('');
     this.article.set(null);
     this.questions.set([]);
+    this.sourceQuestions = [];
     this.answers = [];
     this.submitted.set(false);
     this.saving.set(false);
@@ -291,7 +307,9 @@ export class ReadingExerciseComponent implements OnInit {
       } else if (!isCompleteReadingQuestionSet(questions)) {
         this.legacyIncomplete.set(true);
       }
-      this.questions.set(shuffle(questions).map((question) => ({ ...question, options: shuffle(question.options) })));
+      this.sourceQuestions = questions;
+      this.params.presentationVersion = normalizeReadingPresentationVersion(this.params.presentationVersion);
+      this.questions.set(this.legacyIncomplete() ? questions : presentReadingQuestions(questions, `${article.objectId}:${this.params.presentationVersion}`));
       this.answers = new Array(questions.length).fill('');
       if (latest) this.restoreAttempt(latest.answer);
       this.retryCount = 0;

+ 8 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.spec.ts

@@ -66,4 +66,12 @@ describe('ReadingPdfComponent', () => {
     expect(metadata?.textContent).toContain('生成日期:2026-10-04');
     expect(metadata?.textContent?.indexOf('林同学')).toBeLessThan(metadata?.textContent?.indexOf('三年级') ?? 0);
   });
+  it('spreads correct answers across all four printed labels without changing the correct content', async () => {
+    const { component } = await setup(questions);
+    const positions = component.questions().map(q => q.options.findIndex(o => o.check));
+    expect(new Set(positions).size).toBe(4);
+    expect(Math.max(...[0, 1, 2, 3].map(i => positions.filter(p => p === i).length))).toBe(2);
+    expect(component.questions().every(q => q.options.find(o => o.check)?.value === 'A')).toBeTrue();
+  });
+
 });

+ 2 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.ts

@@ -6,6 +6,7 @@ import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
 import { ReadingCloudService } from './reading-cloud.service';
 import { EnglishReadingService } from './english-reading.service';
+import { presentReadingQuestions } from './reading-presentation';
 import { ReadingArticle, ReadingQuestion, ReadingRouteParams } from './reading.models';
 import { highlightWords, isCompleteReadingQuestionSet, normalizeReadingParams, normalizeReadingReturnUrl, readingStudentName } from './reading.utils';
 
@@ -65,7 +66,7 @@ export class ReadingPdfComponent implements OnInit {
       const questions = await this.reading.getQuestions(article);
       if (!isCompleteReadingQuestionSet(questions)) throw new Error('题目尚未完整保存,请返回练习页补齐后再打印');
       this.article.set(article);
-      this.questions.set(questions);
+      this.questions.set(presentReadingQuestions(questions, `${article.objectId}:${this.params.presentationVersion}`));
     } catch (error) {
       this.error.set(error instanceof Error ? error.message : '加载打印内容失败');
     } finally { this.loading.set(false); }

+ 61 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading-presentation.spec.ts

@@ -0,0 +1,61 @@
+import { ReadingQuestion } from './reading.models';
+import { presentReadingQuestions, remapReadingExplanation, hasPositionDependentReadingContent } from './reading-presentation';
+
+describe('reading presentation', () => {
+  function questions(): ReadingQuestion[] {
+    return Array.from({ length: 5 }, (_, index) => ({
+      objectId: `q-${index}`, type: 'reading-item', index, title: `Question ${index}`,
+      parent: { __type: 'Pointer', className: 'SurveyItem', objectId: 'article' },
+      answer: 'A项正确,B、C、D均错误。',
+      options: ['apple', 'river', 'tree', 'boat'].map((value, i) => ({ value, check: i === 0, answer: i === 0 ? '选项A正确。' : '错误。' })),
+    }));
+  }
+  it('balances every five-question set without mutating input or separating content from correctness', () => {
+    const original = questions(), before = JSON.stringify(original), variants = new Set<string>();
+    for (let seed = 0; seed < 100; seed++) {
+      const result = presentReadingQuestions(original, `article:v${seed}`);
+      const counts = [0, 1, 2, 3].map(i => result.filter(q => q.options[i].check).length);
+      expect([...counts].sort()).toEqual([1, 1, 1, 2]);
+      expect(result.every(q => q.options.find(o => o.check)?.value === 'apple')).toBeTrue();
+      expect(result.map(q => q.objectId).sort()).toEqual(['q-0', 'q-1', 'q-2', 'q-3', 'q-4']);
+      for (const q of result) {
+        const label = 'ABCD'[q.options.findIndex(o => o.check)];
+        expect(q.answer).toContain(`${label}项正确`);
+        expect(q.options.find(o => o.check)?.answer).toBe(`选项${label}正确。`);
+      }
+      variants.add(JSON.stringify(result));
+    }
+    expect(variants.size).toBeGreaterThan(1);
+    expect(JSON.stringify(original)).toBe(before);
+  });
+  it('reproduces the same version regardless of database row order', () => {
+    expect(presentReadingQuestions(questions(), 'article:v1')).toEqual(presentReadingQuestions(questions().reverse(), 'article:v1'));
+  });
+  it('maps references simultaneously while leaving English articles and Plan A unchanged', () => {
+    const labels = { A: 'C', B: 'A', C: 'D', D: 'B' };
+    const pairs = [
+      ['A项正确;B、C、D均错误。', 'C项正确;A、D、B均错误。'],
+      ['选项A;正确选项为A。故选A。B、D为其他义项,C为干扰。', '选项C;正确选项为C。故选C。A、B为其他义项,D为干扰。'],
+      ['A正确;B把将来说成过去;C、D项混淆。', 'C正确;A把将来说成过去;D、B项混淆。'],
+      ['option A is correct; options B and C are wrong.', 'option C is correct; options A and D are wrong.'],
+      ['A cleaner keeps school clean. Plan A is useful. A Fun Day in the City。原文提到A cleaner。', 'A cleaner keeps school clean. Plan A is useful. A Fun Day in the City。原文提到A cleaner。'],
+    ];
+    for (const [input, output] of pairs) expect(remapReadingExplanation(input, labels)).toBe(output);
+  });
+  it('rejects incomplete or invalid sets before producing a print version', () => {
+    expect(() => presentReadingQuestions(questions().slice(0, 1), 'x')).toThrow();
+    const duplicate = questions(); duplicate[0].options[1].value = 'apple';
+    expect(() => presentReadingQuestions(duplicate, 'x')).toThrow();
+    const multiple = questions(); multiple[0].options[1].check = true;
+    expect(() => presentReadingQuestions(multiple, 'x')).toThrow();
+  });
+  it('blocks position-dependent answer content instead of silently changing its meaning', () => {
+    for (const text of ['Both A and B', 'All of the above', 'None of these options', '以上各项', 'The first option']) {
+      expect(hasPositionDependentReadingContent(text)).withContext(text).toBeTrue();
+      const set = questions(); set[0].options[3].value = text;
+      expect(() => presentReadingQuestions(set, 'x')).toThrow();
+    }
+    expect(hasPositionDependentReadingContent('The bird flies above the river.')).toBeFalse();
+    expect(hasPositionDependentReadingContent('The first day of school')).toBeFalse();
+  });
+});

+ 60 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading-presentation.ts

@@ -0,0 +1,60 @@
+import { ReadingQuestion } from './reading.models';
+import { isCompleteReadingQuestionSet } from './reading.utils';
+
+const LABELS = 'ABCD';
+// Match explicit answer references, never every standalone English A/B/C/D.
+const REFERENCES = /(?:正确选项|选项|正确答案|答案)(?:应为|是|为)?\s*[A-D](?![A-Za-z])|(?:故选|应选|选)\s*[A-D](?![A-Za-z])|\b(?:options?|choices?|answer(?:\s+is)?)\s*[::]?\s*[A-D](?:\s*(?:,|and|or)\s*[A-D])*(?![A-Za-z])|(?<![A-Za-z])(?:[A-D]\s*[、,,/和与及或]\s*)+[A-D](?=\s*[\u4e00-\u9fff])|(?<![A-Za-z])[A-D](?=\s*(?:项|正确|错误|为|是|把|将|最|则|均|都|不|属于|描述|提到|符合|没有))/g;
+
+export function remapReadingExplanation(text: string, labels: Readonly<Record<string, string>>): string {
+  return text.replace(REFERENCES, reference => reference.replace(/\b[A-D]\b|(?<![A-Za-z])[A-D](?![A-Za-z])/g, label => labels[label] || label));
+}
+
+export function hasReadingOptionReferences(text: string): boolean {
+  return remapReadingExplanation(text, { A: '<A>', B: '<B>', C: '<C>', D: '<D>' }) !== text;
+}
+
+export function hasPositionDependentReadingContent(text: string): boolean {
+  return hasReadingOptionReferences(text)
+    || /\b(?:both|either|neither)\s+[A-D]\s+(?:and|or|nor)\s+[A-D]\b|\b(?:all|none)\s+of\s+(?:(?:the|these|those)\s+)?(?:above|below|options|answers)\b|\b(?:first|second|third|fourth|last|previous|next)\s+(?:option|answer|choice)\b|以上(?:各项|选项|皆|都)|上述(?:选项|各项)|第[一二三四1234]个?选项/i.test(text);
+}
+
+
+export function presentReadingQuestions(questions: ReadingQuestion[], seed: string): ReadingQuestion[] {
+  if (!isCompleteReadingQuestionSet(questions)) throw new Error('题目不完整或选项无效,无法生成试卷');
+  if (questions.some(q => hasPositionDependentReadingContent(q.title) || q.options.some(o => hasPositionDependentReadingContent(o.value)))) {
+    throw new Error('这份题目含依赖选项位置的内容,请联系老师核对后再打印或练习');
+  }
+  // Stable input order and seeded PRNG make refresh, direct PDF and the exercise agree.
+  let state = 2166136261;
+  for (const char of seed) state = Math.imul(state ^ char.charCodeAt(0), 16777619);
+  const random = () => {
+    state += 0x6D2B79F5;
+    let value = Math.imul(state ^ state >>> 15, 1 | state);
+    value ^= value + Math.imul(value ^ value >>> 7, 61 | value);
+    return ((value ^ value >>> 14) >>> 0) / 4294967296;
+  };
+  const permute = <T>(items: T[]): T[] => {
+    const result = [...items];
+    for (let i = result.length - 1; i > 0; i--) {
+      const j = Math.floor(random() * (i + 1));
+      [result[i], result[j]] = [result[j], result[i]];
+    }
+    return result;
+  };
+  const ordered = permute([...questions].sort((a, b) => a.index - b.index || a.objectId.localeCompare(b.objectId)));
+  const positions = permute([0, 1, 2, 3, Math.floor(random() * 4)]);
+  return ordered.map((question, index) => {
+    const correct = question.options.findIndex(option => option.check);
+    const indexes = permute([0, 1, 2, 3].filter(i => i !== correct));
+    indexes.splice(positions[index], 0, correct);
+    const labels = Object.fromEntries(indexes.map((oldIndex, newIndex) => [LABELS[oldIndex], LABELS[newIndex]]));
+    return {
+      ...question,
+      answer: question.answer === undefined ? undefined : remapReadingExplanation(question.answer, labels),
+      options: indexes.map(i => ({
+        ...question.options[i],
+        answer: question.options[i].answer === undefined ? undefined : remapReadingExplanation(question.options[i].answer!, labels),
+      })),
+    };
+  });
+}

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/reading-prompts.ts

@@ -43,7 +43,7 @@ export const READING_PROMPT_FALLBACKS: Readonly<Record<string, string>> = {
 要求:
 1. 题目要覆盖主旨、细节、词义或简单推理,不得超出文章信息。
 2. 题干和选项使用英文,每题至少有四个选项,且只有一个选项的 check 为 true。
-3. answer 字段使用简洁中文说明答案依据;每个选项的 answer 字段可说明其正误原因。
+3. answer 字段使用简洁中文说明答案依据;每个选项的 answer 字段可说明其正误原因。题干、选项与解析必须使用内容指代,不得引用 A/B/C/D、第一项、以上各项等随选项顺序变化的字母或位置。正确选项的位置应分散。
 4. wordList 填写该题实际涉及的目标生词。
 5. 只输出合法 JSON,不要使用 Markdown 代码块或附加文字。
 `.trim(),

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading.models.ts

@@ -85,6 +85,7 @@ export interface ReadingRouteParams {
   t?: number;
   renew?: boolean;
   sid?: string;
+  presentationVersion?: string;
 }
 
 export interface ReadingHistoryEntry {

+ 6 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading.utils.ts

@@ -47,6 +47,7 @@ export function normalizeReadingParams(value: Record<string, unknown>): ReadingR
     t: Number(params['t']) || undefined,
     renew: params['renew'] === true || params['renew'] === 'true',
     sid: typeof params['sid'] === 'string' ? params['sid'] : undefined,
+    presentationVersion: normalizeReadingPresentationVersion(params['presentationVersion']),
   };
 }
 
@@ -170,3 +171,8 @@ function routeWords(value: unknown): string[] {
   const source = Array.isArray(value) ? value : String(value ?? '').split(',');
   return source.map((word) => String(word).trim()).filter(Boolean);
 }
+
+export function normalizeReadingPresentationVersion(value: unknown): string {
+  return typeof value === 'string' && /^v1(?:-[a-z0-9-]{1,48})?$/.test(value) ? value : 'v1';
+}
+