Forráskód Böngészése

fix(reading): preserve retry state and guard ambiguous answer references

彭峰 4 órája
szülő
commit
dc1d6bfc89

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

@@ -133,4 +133,12 @@ describe('reading question persistence', () => {
   expect(cloud.create).toHaveBeenCalledTimes(5);
  });
 
+ it('rejects positional explanation text even when it contains no letter label',async()=>{
+  const {service,ai}=setup(); let calls=0;
+  ai.json.and.callFake(async()=>{const qs=generated(5);if(calls++===0)qs[0].options[0].answer='The first option is correct.';return {questions:qs};});
+  const result=await service.createQuestions(article,()=>{});
+  expect(ai.json).toHaveBeenCalledTimes(2);
+  expect(result[0].options[0].answer).toBe('正确');
+ });
+
 });

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

@@ -3,7 +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 { hasPositionDependentReadingContent } from './reading-presentation';
 import { READING_PROMPT_FALLBACKS } from './reading-prompts';
 import {
   ReadingArticle, ReadingHistoryEntry, ReadingLog, ReadingOption, ReadingProgress,
@@ -173,8 +173,8 @@ export class EnglishReadingService {
           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;
+            || item.options.some(option => hasPositionDependentReadingContent(option.value) || hasPositionDependentReadingContent(option.answer || ''))
+            || hasPositionDependentReadingContent(question.answer || '')) return false;
           const title = item.title.trim().toLowerCase();
           if (titles.has(title)) return false;
           titles.add(title);

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

@@ -251,4 +251,20 @@ describe('ReadingExerciseComponent', () => {
     expect(component.answers.every(a => a === '')).toBeTrue();
   });
 
+  it('does not resurrect the previous submission after restarting and refreshing, but restores a new retry submission', async () => {
+    component.params.sid = article.objectId;
+    reading.latestLog.and.resolveTo(savedLog);
+    await component.load();
+    expect(component.submitted()).toBeTrue();
+    await component.reset();
+    await component.load();
+    expect(component.submitted()).toBeFalse();
+    expect(component.answers.every(a => a === '')).toBeTrue();
+    const retriedLog = { ...savedLog, objectId: 'log-2' };
+    reading.latestLog.and.resolveTo(retriedLog);
+    await component.load();
+    expect(component.submitted()).toBeTrue();
+    expect(component.score()).toBe(1);
+  });
+
 });

+ 10 - 7
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.ts

@@ -52,6 +52,7 @@ export class ReadingExerciseComponent implements OnInit {
   answers: string[] = [];
   params!: ReadingRouteParams;
   private sourceQuestions: ReadingQuestion[] = [];
+  private latestLogId?: string;
   private retryCount = 0;
   private readonly maxRetry = 5;
   private historyPageIndex = 0;
@@ -128,7 +129,8 @@ export class ReadingExerciseComponent implements OnInit {
     this.saving.set(true);
     this.saveFailed.set(false);
     try {
-      await this.reading.saveLog(article, this.questions(), this.answers, score);
+      const saved = await this.reading.saveLog(article, this.questions(), this.answers, score);
+      this.latestLogId = saved.objectId;
       this.score.set(score);
       this.submitted.set(true);
       this.platform.toast('答题记录已保存', 'success');
@@ -150,7 +152,7 @@ export class ReadingExerciseComponent implements OnInit {
   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 };
+    const params = { ...this.params, presentationVersion, retryAfterLogId: this.latestLogId };
     this.saving.set(true);
     try {
       const questions = presentReadingQuestions(this.sourceQuestions, `${this.article()!.objectId}:${presentationVersion}`);
@@ -178,17 +180,17 @@ export class ReadingExerciseComponent implements OnInit {
 
   async createNewArticle(): Promise<void> {
     if (this.legacyIncomplete()) {
-      const params = { ...this.params, presentationVersion: 'v1', sid: undefined, renew: true, wordList: this.params.wordList.join(','), t: Date.now() };
+      const params = { ...this.params, presentationVersion: 'v1', retryAfterLogId: undefined, 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, presentationVersion: 'v1', sid: undefined, renew: true };
+        this.params = { ...this.params, presentationVersion: 'v1', retryAfterLogId: undefined, sid: undefined, renew: true };
         await this.load();
       }
       return;
     }
     if (!this.remainingArticles()) return;
     await this.router.navigate(['/words/go-reading'], {
-      queryParams: { ...this.params, presentationVersion: 'v1', wordList: this.params.wordList.join(','), type: '随机', theme: '随机', renew: true, t: Date.now() },
+      queryParams: { ...this.params, presentationVersion: 'v1', retryAfterLogId: undefined, wordList: this.params.wordList.join(','), type: '随机', theme: '随机', renew: true, t: Date.now() },
     });
   }
 
@@ -239,7 +241,7 @@ export class ReadingExerciseComponent implements OnInit {
       replaceUrl: true,
     });
     if (!navigated) return;
-    this.params = { ...this.params, ...values, sid: entry.article.objectId, renew:false, presentationVersion: 'v1' };
+    this.params = { ...this.params, ...values, sid: entry.article.objectId, renew:false, presentationVersion: 'v1', retryAfterLogId: undefined };
     await this.load();
   }
 
@@ -289,6 +291,7 @@ export class ReadingExerciseComponent implements OnInit {
       }
 
       const latest = await this.reading.latestLog(article.objectId);
+      this.latestLogId = latest?.objectId;
       let questions = await this.reading.getQuestions(article);
       if (!latest) {
         if (!questions.length) {
@@ -311,7 +314,7 @@ export class ReadingExerciseComponent implements OnInit {
       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);
+      if (latest && (this.legacyIncomplete() || latest.objectId !== this.params.retryAfterLogId)) this.restoreAttempt(latest.answer);
       this.retryCount = 0;
     } catch (error) {
       const message = error instanceof Error ? error.message : '阅读内容加载失败';

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

@@ -34,6 +34,9 @@ describe('reading presentation', () => {
   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是脂溶性维生素。Plan A是可行方案。', '维生素A是脂溶性维生素。Plan A是可行方案。'],
+      ['A:符合原文;B:时间错误。', 'C:符合原文;A:时间错误。'],
+      ['A is correct because the story says so.', 'C is correct because the story says so.'],
       ['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项混淆。'],
@@ -58,4 +61,13 @@ describe('reading presentation', () => {
     expect(hasPositionDependentReadingContent('The bird flies above the river.')).toBeFalse();
     expect(hasPositionDependentReadingContent('The first day of school')).toBeFalse();
   });
+  it('refuses to reorder historical positional explanations that cannot be mapped reliably', () => {
+    for (const field of ['question', 'option']) {
+      const set = questions();
+      if (field === 'question') set[0].answer = '第一个选项正确。';
+      else set[0].options[0].answer = 'The first option is correct.';
+      expect(() => presentReadingQuestions(set, 'x')).toThrow();
+    }
+  });
+
 });

+ 6 - 3
projects/xiaoshu-mobile/src/app/features/english-reading/reading-presentation.ts

@@ -3,7 +3,8 @@ 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;
+const REFERENCES = /(?:正确选项|选项|正确答案|答案)(?:应为|是|为)?\s*[::]?\s*[A-D](?:\s*[、,,/和与及或]\s*[A-D])*(?![A-Za-z])|(?:故选|应选|选择|选)\s*[A-D](?![A-Za-z])|\b(?:[Oo]ptions?|[Cc]hoices?|[Aa]nswer(?:\s+is)?)\s*[::]?\s*[A-D](?:\s*(?:,|and|or)\s*[A-D])*(?![A-Za-z])|(?<![A-Za-z\u4e00-\u9fff])(?:[A-D]\s*[、,,/和与及或]\s*)+[A-D](?=\s*[\u4e00-\u9fff])|(?<![A-Za-z])[A-D](?=\s*项)|(?:^|[。;;,,\n])\s*[A-D](?=\s*(?:[::]|正确|错误|为|是|把|将|最|则|均|都|不|属于|描述|提到|符合|没有|\s+(?:is|are)\s+(?:correct|incorrect|right|wrong)))/g;
+const POSITIONAL = /\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;
 
 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));
@@ -14,8 +15,7 @@ export function hasReadingOptionReferences(text: string): boolean {
 }
 
 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);
+  return hasReadingOptionReferences(text) || POSITIONAL.test(text);
 }
 
 
@@ -24,6 +24,9 @@ export function presentReadingQuestions(questions: ReadingQuestion[], seed: stri
   if (questions.some(q => hasPositionDependentReadingContent(q.title) || q.options.some(o => hasPositionDependentReadingContent(o.value)))) {
     throw new Error('这份题目含依赖选项位置的内容,请联系老师核对后再打印或练习');
   }
+  if (questions.some(q => POSITIONAL.test(q.answer || '') || q.options.some(o => POSITIONAL.test(o.answer || '')))) {
+    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);

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

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

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

@@ -48,6 +48,7 @@ export function normalizeReadingParams(value: Record<string, unknown>): ReadingR
     renew: params['renew'] === true || params['renew'] === 'true',
     sid: typeof params['sid'] === 'string' ? params['sid'] : undefined,
     presentationVersion: normalizeReadingPresentationVersion(params['presentationVersion']),
+    retryAfterLogId: typeof params['retryAfterLogId'] === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(params['retryAfterLogId']) ? params['retryAfterLogId'] : undefined,
   };
 }
 
@@ -175,4 +176,3 @@ function routeWords(value: unknown): string[] {
 export function normalizeReadingPresentationVersion(value: unknown): string {
   return typeof value === 'string' && /^v1(?:-[a-z0-9-]{1,48})?$/.test(value) ? value : 'v1';
 }
-