@zhouchangui/math-ati 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/index.js CHANGED
@@ -152,6 +152,54 @@ app.put('/api/profile', async (req, res, next) => {
152
152
  }
153
153
  });
154
154
 
155
+ // ── First-time setup ────────────────────────────────────────────────
156
+
157
+ app.get('/api/setup/status', async (req, res, next) => {
158
+ try {
159
+ const profile = await readJson(paths.profile, null).catch(() => null);
160
+ const needsSetup = !profile?.setupCompletedAt;
161
+ res.json({ needsSetup });
162
+ } catch (error) {
163
+ next(error);
164
+ }
165
+ });
166
+
167
+ app.post('/api/setup', async (req, res, next) => {
168
+ try {
169
+ const body = req.body || {};
170
+ const profileFields = body.profile || {};
171
+ const llm = body.llm || {};
172
+
173
+ // Save profile with setup completion marker
174
+ const current = await readJson(paths.profile, {}).catch(() => ({}));
175
+ const profile = {
176
+ ...current,
177
+ name: String(profileFields.name || current.name || '学生').trim(),
178
+ gender: String(profileFields.gender || current.gender || '').trim(),
179
+ age: Number.isFinite(Number(profileFields.age)) ? Number(profileFields.age) : current.age || 14,
180
+ stage: String(profileFields.stage || current.stage || '').trim(),
181
+ primaryGoal: String(profileFields.primaryGoal || current.primaryGoal || '').trim(),
182
+ priorityTrack: String(profileFields.priorityTrack || current.priorityTrack || '').trim(),
183
+ preferences: String(profileFields.preferences || current.preferences || '').trim(),
184
+ profileNotes: String(profileFields.profileNotes || current.profileNotes || '').trim(),
185
+ setupCompletedAt: new Date().toISOString(),
186
+ updatedAt: new Date().toISOString()
187
+ };
188
+ await writeJson(paths.profile, profile);
189
+
190
+ // Save LLM settings
191
+ await writeLlmSettings({
192
+ baseUrl: llm.baseUrl,
193
+ model: llm.model,
194
+ apiKey: llm.apiKey
195
+ });
196
+
197
+ res.json({ ok: true });
198
+ } catch (error) {
199
+ next(error);
200
+ }
201
+ });
202
+
155
203
  app.get('/api/settings/llm', async (req, res, next) => {
156
204
  try {
157
205
  res.json(await readLlmSettings());
@@ -13,10 +13,10 @@ import {
13
13
  } from './fileStore.js';
14
14
  import { promptPayload, readPrompt } from './promptStore.js';
15
15
 
16
- const KNOWLEDGE_PAGE_TIMEOUT_MS = Number(process.env.KNOWLEDGE_EXTRACT_PAGE_TIMEOUT_MS || 180000);
17
- const KNOWLEDGE_SUMMARY_TIMEOUT_MS = Number(process.env.KNOWLEDGE_EXTRACT_SUMMARY_TIMEOUT_MS || 120000);
18
- const KNOWLEDGE_PAGE_RETRIES = Number(process.env.KNOWLEDGE_EXTRACT_PAGE_RETRIES || 2);
19
- const KNOWLEDGE_SUMMARY_RETRIES = Number(process.env.KNOWLEDGE_EXTRACT_SUMMARY_RETRIES || 2);
16
+ const KNOWLEDGE_PAGE_TIMEOUT_MS = Number(process.env.KNOWLEDGE_EXTRACT_PAGE_TIMEOUT_MS || 300000);
17
+ const KNOWLEDGE_SUMMARY_TIMEOUT_MS = Number(process.env.KNOWLEDGE_EXTRACT_SUMMARY_TIMEOUT_MS || 600000);
18
+ const KNOWLEDGE_PAGE_RETRIES = Number(process.env.KNOWLEDGE_EXTRACT_PAGE_RETRIES || 3);
19
+ const KNOWLEDGE_SUMMARY_RETRIES = Number(process.env.KNOWLEDGE_EXTRACT_SUMMARY_RETRIES || 3);
20
20
  const KNOWLEDGE_SUMMARY_PAGE_CHUNK_SIZE = Number(process.env.KNOWLEDGE_SUMMARY_PAGE_CHUNK_SIZE || 4);
21
21
  const KNOWLEDGE_MAX_CORE_POINTS = Number(process.env.KNOWLEDGE_MAX_CORE_POINTS || 24);
22
22
  const KNOWLEDGE_MAX_MISTAKE_POINTS = Number(process.env.KNOWLEDGE_MAX_MISTAKE_POINTS || 8);
@@ -213,6 +213,7 @@ export async function extractChapterPage({
213
213
  pageCount = 0,
214
214
  force = false,
215
215
  extractProfile = null,
216
+ chapterStructure = '',
216
217
  onProgress = null
217
218
  }) {
218
219
  await ensureChapterWorkspace(chapter);
@@ -249,6 +250,7 @@ export async function extractChapterPage({
249
250
  `章节:${chapter.id} ${chapter.fullTitle}`,
250
251
  `主线:${chapter.track}`,
251
252
  `提取策略:考点和易错点优先;不要把例子拆成独立知识点;不要补充图片没有出现的内容。`,
253
+ chapterStructure ? `\n以下是本《${chapter.fullTitle}》的章节整体结构分析,供逐页提取时参考。请在提取本页时注意:\n- 本页在章节中的大致角色\n- 本页涉及的核心概念是否已在结构分析中列出\n- 本页知识点与前后页的关联关系\n\n${chapterStructure.slice(0, 3000)}` : '',
252
254
  '',
253
255
  '输出必须是 Markdown,且只包含以下标题:',
254
256
  '# 页面知识提取',
@@ -371,6 +373,23 @@ function normalizePointForBudget(point) {
371
373
  formulas: Array.isArray(point.formulas) ? point.formulas.filter(Boolean).slice(0, 4) : [],
372
374
  pitfalls: Array.isArray(point.pitfalls) ? point.pitfalls.filter(Boolean).slice(0, 5) : [],
373
375
  examples: Array.isArray(point.examples) ? point.examples.filter(Boolean).slice(0, 4) : [],
376
+ teachingTips: point.teachingTips && typeof point.teachingTips === 'object'
377
+ ? {
378
+ commonMisconceptions: Array.isArray(point.teachingTips.commonMisconceptions)
379
+ ? point.teachingTips.commonMisconceptions.filter(Boolean).slice(0, 3)
380
+ : [],
381
+ scaffoldingOrder: Array.isArray(point.teachingTips.scaffoldingOrder)
382
+ ? point.teachingTips.scaffoldingOrder.filter(Boolean).slice(0, 4)
383
+ : [],
384
+ checkUnderstandingQuestions: Array.isArray(point.teachingTips.checkUnderstandingQuestions)
385
+ ? point.teachingTips.checkUnderstandingQuestions.filter(Boolean).slice(0, 2)
386
+ : []
387
+ }
388
+ : point.teachingTips || {
389
+ commonMisconceptions: [],
390
+ scaffoldingOrder: [],
391
+ checkUnderstandingQuestions: []
392
+ },
374
393
  questionTemplates: Array.isArray(point.questionTemplates) && point.questionTemplates.length
375
394
  ? point.questionTemplates.filter((template) => Array.isArray(template)).slice(0, 3)
376
395
  : [[
@@ -403,6 +422,27 @@ function mergeDuplicatePoints(points) {
403
422
  ))
404
423
  .slice(0, 3);
405
424
  current.sources = [...new Set([...(current.sources || []), ...(point.sources || [])])];
425
+ if (point.teachingTips && typeof point.teachingTips === 'object') {
426
+ const currentTips = current.teachingTips || { commonMisconceptions: [], scaffoldingOrder: [], checkUnderstandingQuestions: [] };
427
+ const pointTips = point.teachingTips;
428
+ current.teachingTips = {
429
+ commonMisconceptions: [
430
+ ...new Set([
431
+ ...(Array.isArray(currentTips.commonMisconceptions) ? currentTips.commonMisconceptions : []),
432
+ ...(Array.isArray(pointTips.commonMisconceptions) ? pointTips.commonMisconceptions : [])
433
+ ])
434
+ ].slice(0, 4),
435
+ scaffoldingOrder: Array.isArray(pointTips.scaffoldingOrder) && pointTips.scaffoldingOrder.length
436
+ ? pointTips.scaffoldingOrder
437
+ : currentTips.scaffoldingOrder || [],
438
+ checkUnderstandingQuestions: [
439
+ ...new Set([
440
+ ...(Array.isArray(currentTips.checkUnderstandingQuestions) ? currentTips.checkUnderstandingQuestions : []),
441
+ ...(Array.isArray(pointTips.checkUnderstandingQuestions) ? pointTips.checkUnderstandingQuestions : [])
442
+ ])
443
+ ].slice(0, 3)
444
+ };
445
+ }
406
446
  }
407
447
  return [...byKey.values()];
408
448
  }
@@ -429,16 +469,18 @@ function enforceKnowledgeBudget(chapter, doc, profile) {
429
469
  else corePoints.push(point);
430
470
  }
431
471
  }
472
+ // Knowledge points may be de-duplicated and merged, but must never be dropped
473
+ // just to fit a numeric budget — losing knowledge points corrupts the chapter's
474
+ // coverage/mastery loop. The budget values are only a target hint passed to the
475
+ // extract/summary agent prompts upstream; the post-processing here only de-dups.
432
476
  const dedupedCore = mergeDuplicatePoints(corePoints)
433
477
  .sort((a, b) => pointPriority(b) - pointPriority(a))
434
- .slice(0, maxCore)
435
478
  .map((point, index) => ({
436
479
  ...point,
437
480
  id: `${chapter.id}-kp-${String(index + 1).padStart(2, '0')}`
438
481
  }));
439
482
  const dedupedMistakes = mergeDuplicatePoints(mistakePoints)
440
483
  .sort((a, b) => pointPriority(b) - pointPriority(a))
441
- .slice(0, maxMistakes)
442
484
  .map((point, index) => ({
443
485
  ...point,
444
486
  id: `${chapter.id}-mistake-${String(index + 1).padStart(2, '0')}`
@@ -495,6 +537,14 @@ function localMergeChapter(chapter, pageExtracts) {
495
537
  point.title
496
538
  ]
497
539
  ],
540
+ teachingTips: {
541
+ commonMisconceptions: (point.pitfalls || []).slice(0, 2)
542
+ .map((pitfall) => `常见的误解:${pitfall}`),
543
+ scaffoldingOrder: [`先理解「${point.title}」的基本定义`, `再通过例子巩固`, `最后独立完成变式题`],
544
+ checkUnderstandingQuestions: [
545
+ `用自己的话解释什么是「${point.title}」,并举一个例子。`
546
+ ]
547
+ },
498
548
  sources: point.sources || []
499
549
  }))
500
550
  },
@@ -514,6 +564,11 @@ function localMergeChapter(chapter, pageExtracts) {
514
564
  mistake.errorType || mistake.title
515
565
  ]
516
566
  ],
567
+ teachingTips: {
568
+ commonMisconceptions: [mistake.description || mistake.errorType || mistake.title || '易错点'].filter(Boolean).slice(0, 2),
569
+ scaffoldingOrder: ['先识别错误类型', '再用正确方法重新做一遍'],
570
+ checkUnderstandingQuestions: ['这个易错点最容易在什么情况下出现?如何避免?']
571
+ },
517
572
  sources: mistake.sources || []
518
573
  }))
519
574
  }
@@ -571,6 +626,11 @@ function knowledgeSummarySchema(chapter) {
571
626
  pitfalls: ['string'],
572
627
  examples: ['string'],
573
628
  questionTemplates: [['stem', 'answer', 'expectedErrorType']],
629
+ teachingTips: {
630
+ commonMisconceptions: ['string'],
631
+ scaffoldingOrder: ['string'],
632
+ checkUnderstandingQuestions: ['string']
633
+ },
574
634
  sources: ['image filename']
575
635
  }]
576
636
  }],
@@ -608,62 +668,97 @@ async function summarizePageChunk({ chapter, pages, chunkIndex, chunkCount, syst
608
668
  chunkCount,
609
669
  pageCount: pages.length
610
670
  });
611
- const agent = await callChatTextAgent({
612
- system: systemPrompt,
613
- timeoutMs: KNOWLEDGE_SUMMARY_TIMEOUT_MS,
614
- retries: KNOWLEDGE_SUMMARY_RETRIES,
615
- temperature: 0.1,
616
- user: [
617
- `任务:合并《${chapter.fullTitle}》第 ${chunkIndex + 1}/${chunkCount} 组逐页 Markdown 提取结果。`,
618
- '',
619
- `本组页面:${pageLabels}`,
620
- `数量控制:核心知识点不超过 ${Math.ceil(normalizedProfile.maxCorePointCount / chunkCount) + 4} 个;易错点不超过 ${Math.ceil(normalizedProfile.maxMistakePointCount / chunkCount) + 2} 个。`,
621
- '',
622
- '要求:',
623
- '- 只基于输入页面合并知识点,不新增页面没有依据的内容。',
624
- '- 合并同义、过细、重复候选,保留来源页。',
625
- '- 优先保留考试常见考点、易错边界、几何概念辨析和可出题的方法。',
626
- '- 输出 Markdown,不输出 JSON。',
627
- '- 必须包含标题:# 分组知识汇总、## 知识点覆盖、## 易错题专项、## 合并说明。',
628
- '- 每个知识点用三级标题,包含:摘要、来源、公式、易错边界、出题模板。',
629
- '',
630
- '逐页 Markdown:',
631
- ...pages.map((page) => [
632
- `\n---\n`,
633
- `来源页:${page.imageFile}`,
634
- page.markdown || ''
635
- ].join('\n'))
636
- ].join('\n'),
637
- onAttempt: ({ phase, attempt, attempts, delayMs, result }) => {
638
- if (phase === 'start') {
639
- onProgress?.({
640
- step: 'knowledge_extract.summary.chunk.attempt',
641
- message: `知识点分组 ${chunkIndex + 1}/${chunkCount}:第 ${attempt}/${attempts} 次尝试。`,
642
- attempt,
643
- attempts
644
- });
645
- }
646
- if (phase === 'retry') {
647
- onProgress?.({
648
- step: 'knowledge_extract.summary.chunk.retry',
649
- message: `知识点分组 ${chunkIndex + 1}/${chunkCount} 遇到${retryReasonText(result?.reason)},${Math.round(delayMs / 1000)} 秒后自动重试。`,
650
- attempt,
651
- attempts,
652
- reason: result?.reason || null
653
- });
654
- }
671
+ // The prompt requires a structured Markdown doc ("# 分组知识汇总", "## 知识点覆盖",
672
+ // "## 易错题专项"). Some models leak reasoning as a leading sentence and skip the
673
+ // requested structure, producing a chunk with zero knowledge points. callChatTextAgent
674
+ // returns responseFormat: 'text', so such leakage is reported as ok and is not retried
675
+ // at the agent level. Retry the chunk in place so one bad chunk does not force a whole-
676
+ // chapter redo (which would discard the other chunk's valid cached summary).
677
+ const requiredHeadings = /^#\s+分组知识汇总/m;
678
+ const structureAttempts = Number(process.env.KNOWLEDGE_SUMMARY_STRUCTURE_RETRIES || 3);
679
+ const userPayload = [
680
+ `任务:合并《${chapter.fullTitle}》第 ${chunkIndex + 1}/${chunkCount} 组逐页 Markdown 提取结果。`,
681
+ '',
682
+ `本组页面:${pageLabels}`,
683
+ `数量控制:核心知识点不超过 ${Math.ceil(normalizedProfile.maxCorePointCount / chunkCount) + 4} 个;易错点不超过 ${Math.ceil(normalizedProfile.maxMistakePointCount / chunkCount) + 2} 个。`,
684
+ '',
685
+ '要求:',
686
+ '- 只基于输入页面合并知识点,不新增页面没有依据的内容。',
687
+ '- 合并同义、过细、重复候选,保留来源页。',
688
+ '- 优先保留考试常见考点、易错边界、几何概念辨析和可出题的方法。',
689
+ '- 输出 Markdown,不输出 JSON。',
690
+ '- 必须包含标题:# 分组知识汇总、## 知识点覆盖、## 易错题专项、## 合并说明。',
691
+ '- 每个知识点用三级标题,包含:摘要、来源、公式、易错边界、出题模板。直接输出结果,不要输出思考过程或开场白。',
692
+ '',
693
+ '逐页 Markdown:',
694
+ ...pages.map((page) => [
695
+ `\n---\n`,
696
+ `来源页:${page.imageFile}`,
697
+ page.markdown || ''
698
+ ].join('\n'))
699
+ ].join('\n');
700
+ const handleAttempt = ({ phase, attempt, attempts, delayMs, result }) => {
701
+ if (phase === 'start') {
702
+ onProgress?.({
703
+ step: 'knowledge_extract.summary.chunk.attempt',
704
+ message: `知识点分组 ${chunkIndex + 1}/${chunkCount}:第 ${attempt}/${attempts} 次尝试。`,
705
+ attempt,
706
+ attempts
707
+ });
655
708
  }
656
- });
657
- if (!agent.ok || !agent.data) {
709
+ if (phase === 'retry') {
710
+ onProgress?.({
711
+ step: 'knowledge_extract.summary.chunk.retry',
712
+ message: `知识点分组 ${chunkIndex + 1}/${chunkCount} 遇到${retryReasonText(result?.reason)},${Math.round(delayMs / 1000)} 秒后自动重试。`,
713
+ attempt,
714
+ attempts,
715
+ reason: result?.reason || null
716
+ });
717
+ }
718
+ };
719
+ let markdown = '';
720
+ let pointHeadings = 0;
721
+ for (let structureAttempt = 1; structureAttempt <= structureAttempts; structureAttempt += 1) {
722
+ const agent = await callChatTextAgent({
723
+ system: systemPrompt,
724
+ timeoutMs: KNOWLEDGE_SUMMARY_TIMEOUT_MS,
725
+ retries: KNOWLEDGE_SUMMARY_RETRIES,
726
+ temperature: 0.1,
727
+ user: userPayload,
728
+ onAttempt: handleAttempt
729
+ });
730
+ if (!agent.ok || !agent.data) {
731
+ throw knowledgeExtractionError(
732
+ agent.reason || 'empty_response',
733
+ `知识点分组 ${chunkIndex + 1}/${chunkCount} 合并失败,已尝试 ${agent.attempts || 1} 次。${agent.detail || ''}`.trim()
734
+ );
735
+ }
736
+ markdown = String(agent.data || '').trim();
737
+ pointHeadings = (markdown.match(/^###\s+/gm) || []).length;
738
+ if (requiredHeadings.test(markdown) && pointHeadings > 0) {
739
+ break;
740
+ }
741
+ onProgress?.({
742
+ step: 'knowledge_extract.summary.chunk.structure_retry',
743
+ message: `知识点分组 ${chunkIndex + 1}/${chunkCount} 第 ${structureAttempt}/${structureAttempts} 次返回缺少结构或知识点,重新生成。`,
744
+ chunkIndex: chunkIndex + 1,
745
+ chunkCount,
746
+ structureAttempt,
747
+ structureAttempts
748
+ });
749
+ if (structureAttempt < structureAttempts) {
750
+ await new Promise((resolve) => setTimeout(resolve, 2000));
751
+ }
752
+ }
753
+ if (!requiredHeadings.test(markdown) || pointHeadings === 0) {
658
754
  throw knowledgeExtractionError(
659
- agent.reason || 'empty_response',
660
- `知识点分组 ${chunkIndex + 1}/${chunkCount} 合并失败,已尝试 ${agent.attempts || 1} 次。${agent.detail || ''}`.trim()
755
+ 'invalid_chunk_structure',
756
+ `知识点分组 ${chunkIndex + 1}/${chunkCount} 输出缺少必需的 Markdown 结构或知识点,已重试 ${structureAttempts} 次。`
661
757
  );
662
758
  }
663
- const markdown = String(agent.data || '').trim();
664
759
  await mkdir(chunkSummaryDir(chapter.id), { recursive: true });
665
760
  await writeFile(chunkSummaryPath(chapter.id, chunkIndex), `${markdown}\n`, 'utf8');
666
- const knowledgePointCount = (markdown.match(/^###\s+/gm) || []).length;
761
+ const knowledgePointCount = pointHeadings;
667
762
  onProgress?.({
668
763
  step: 'knowledge_extract.summary.chunk.done',
669
764
  message: `知识点分组 ${chunkIndex + 1}/${chunkCount} 合并完成,得到约 ${knowledgePointCount} 个候选点。`,
@@ -790,11 +885,25 @@ export async function summarizeChapterExtraction({
790
885
  chunkCount: chunkDocs.length
791
886
  });
792
887
  let finalDoc = null;
793
- if (pageChunks.length > 1) {
794
- finalDoc = localMergeChunkSummaries(chapter, chunkDocs);
888
+ // Prefer the deterministic local merge so the final chapter doc is built
889
+ // from real per-page / per-chunk agent outputs (now structurally validated,
890
+ // with chunk-level retries on bad output) instead of an additional free-form
891
+ // LLM merge that can lose points or return invalid JSON. When the chapter
892
+ // fits in a single chunk the chunk summary is skipped, so fall back to
893
+ // localMergeChapter which parses per-page knowledge points directly.
894
+ const localDoc = chunkDocs.length > 0
895
+ ? localMergeChunkSummaries(chapter, chunkDocs)
896
+ : local;
897
+ const hasUsableSections = (localDoc.sections || []).some(
898
+ (section) => Array.isArray(section.points) && section.points.length > 0
899
+ );
900
+ if (hasUsableSections) {
901
+ finalDoc = localDoc;
795
902
  onProgress?.({
796
903
  step: 'knowledge_extract.summary.final.local',
797
- message: `已从 ${chunkDocs.length} 个分组 Markdown 生成最终章节知识草稿,正在去重和限量。`,
904
+ message: `已从 ${chunkDocs.length} 个分组 Markdown 本地合并为最终章节知识,共约 ${
905
+ (localDoc.sections || []).reduce((sum, s) => sum + (s.points || []).length, 0)
906
+ } 个候选点。`,
798
907
  chunkCount: chunkDocs.length
799
908
  });
800
909
  } else {
@@ -878,6 +987,55 @@ export async function extractChapterKnowledge({
878
987
  pageCount: scopedImages.length
879
988
  });
880
989
  const pageExtracts = [];
990
+ // Phase 0: analyze chapter structure with sampled pages
991
+ const structureSampleSize = Math.min(
992
+ Number(process.env.KNOWLEDGE_STRUCTURE_SAMPLE_PAGES || 6),
993
+ scopedImages.length
994
+ );
995
+ let chapterStructure = '';
996
+ const structureCachePath = path.join(chapterDataPaths(chapter.id).context, 'chapter_structure.md');
997
+ const cachedStructure = force ? '' : await readFile(structureCachePath, 'utf8').catch(() => '');
998
+ if (cachedStructure) {
999
+ chapterStructure = cachedStructure;
1000
+ onProgress?.({
1001
+ step: 'knowledge_extract.structure.cached',
1002
+ message: '章节结构分析已有缓存,直接复用。'
1003
+ });
1004
+ } else {
1005
+ onProgress?.({
1006
+ step: 'knowledge_extract.structure.start',
1007
+ message: `正在分析《${chapter.fullTitle}》章节结构(抽样 ${structureSampleSize}/${scopedImages.length} 页)。`
1008
+ });
1009
+ const sampleImages = scopedImages.slice(0, structureSampleSize);
1010
+ const structurePrompt = await readPrompt('knowledge-structure.system.md');
1011
+ const structureAgent = await callVisionTextAgent({
1012
+ timeoutMs: Math.max(120000, KNOWLEDGE_PAGE_TIMEOUT_MS),
1013
+ retries: 1,
1014
+ system: structurePrompt,
1015
+ text: [
1016
+ `任务:快速浏览《${chapter.fullTitle}》(${chapter.track})的章节图片,输出整体结构框架。`,
1017
+ '',
1018
+ '要求:只做结构概览,不做详细提取。识别核心概念、主要公式/法则、常见易错类型、每页角色(概念引入/定义/推导/例题/总结)。'
1019
+ ].join('\n'),
1020
+ imagePaths: sampleImages,
1021
+ onAttempt: null
1022
+ });
1023
+ if (structureAgent.ok && structureAgent.data) {
1024
+ chapterStructure = String(structureAgent.data || '').trim();
1025
+ await mkdir(path.dirname(structureCachePath), { recursive: true });
1026
+ await writeFile(structureCachePath, `${chapterStructure}\n`, 'utf8');
1027
+ onProgress?.({
1028
+ step: 'knowledge_extract.structure.done',
1029
+ message: `章节结构分析完成,已缓存。`
1030
+ });
1031
+ } else {
1032
+ chapterStructure = `# 章节结构分析\n\n## 章节主题\n${chapter.fullTitle}\n\n## 核心概念\n(结构分析未能完成,逐页提取将独立进行)\n`;
1033
+ onProgress?.({
1034
+ step: 'knowledge_extract.structure.failed',
1035
+ message: `章节结构分析失败:${structureAgent.reason || 'unknown'},继续逐页提取。`
1036
+ });
1037
+ }
1038
+ }
881
1039
  for (let index = 0; index < scopedImages.length; index += 1) {
882
1040
  pageExtracts.push(await extractChapterPage({
883
1041
  chapter,
@@ -886,6 +1044,7 @@ export async function extractChapterKnowledge({
886
1044
  pageCount: scopedImages.length,
887
1045
  force,
888
1046
  extractProfile: normalizedProfile,
1047
+ chapterStructure,
889
1048
  onProgress
890
1049
  }));
891
1050
  }
@@ -295,5 +295,74 @@ export async function syncKnowledgeMasteryMarkers(chapterId) {
295
295
  const marker = `\n\n<!-- mastery-updated-at: ${new Date().toISOString()} -->\n`;
296
296
  await writeFile(docPath, `${currentMarkdown.replace(/\n\n<!-- mastery-updated-at: .*? -->\n?$/s, '').trim()}${marker}`, 'utf8');
297
297
  await syncGlobalIndexes();
298
+ await updateStudentLearningState();
298
299
  return chapterMastery;
299
300
  }
301
+
302
+ export async function updateStudentLearningState() {
303
+ const profile = await readJson(paths.profile, {});
304
+ const accuracyHistoryPath = path.join(paths.rootDir, 'data', 'global', '.accuracy_history.json');
305
+ let accuracyHistory = await readJson(accuracyHistoryPath, []);
306
+
307
+ // Collect accuracy from recent chapter mastery snapshots
308
+ const chapters = await readJson(paths.chapters, []);
309
+ let latestAccuracy = null;
310
+ for (const chapter of chapters) {
311
+ const chapterPaths = chapterDataPaths(chapter.id);
312
+ const chapterMastery = await readJson(chapterPaths.mastery, null);
313
+ if (!chapterMastery?.coverage) continue;
314
+ const { totalQuestions = 0, correctQuestions = 0 } = chapterMastery.coverage;
315
+ if (totalQuestions > 0) {
316
+ latestAccuracy = Math.round((correctQuestions / totalQuestions) * 100);
317
+ break;
318
+ }
319
+ }
320
+
321
+ if (latestAccuracy !== null) {
322
+ accuracyHistory.push({
323
+ date: new Date().toISOString(),
324
+ accuracy: latestAccuracy
325
+ });
326
+ // Keep last 10 entries
327
+ if (accuracyHistory.length > 10) {
328
+ accuracyHistory = accuracyHistory.slice(-10);
329
+ }
330
+ await writeJson(accuracyHistoryPath, accuracyHistory);
331
+ }
332
+
333
+ // Derive trend from last 3 entries
334
+ const recent = accuracyHistory.slice(-3);
335
+ let recentAccuracyTrend = 'stable';
336
+ if (recent.length >= 2) {
337
+ const first = recent[0].accuracy;
338
+ const last = recent[recent.length - 1].accuracy;
339
+ if (last >= first + 10) recentAccuracyTrend = 'improving';
340
+ else if (last <= first - 10) recentAccuracyTrend = 'declining';
341
+ }
342
+
343
+ // Update frustration topics based on mastery stats
344
+ const frustrationTopics = [];
345
+ const allMasteryPoints = [];
346
+ for (const chapter of chapters) {
347
+ const chapterPaths = chapterDataPaths(chapter.id);
348
+ const chapterMastery = await readJson(chapterPaths.mastery, null);
349
+ if (chapterMastery?.points) {
350
+ allMasteryPoints.push(...Object.values(chapterMastery.points));
351
+ }
352
+ }
353
+ for (const point of allMasteryPoints) {
354
+ if (point.status === 'needs_review' && (point.wrongCount || 0) >= 3) {
355
+ frustrationTopics.push(point.id);
356
+ }
357
+ }
358
+
359
+ const learningState = {
360
+ ...(profile.learningState || {}),
361
+ recentAccuracyTrend: recentAccuracyTrend || profile.learningState?.recentAccuracyTrend || 'stable',
362
+ frustrationTopics: frustrationTopics.slice(0, 5),
363
+ preferredPace: profile.learningState?.preferredPace || 'steady',
364
+ lastSessionFatigue: profile.learningState?.lastSessionFatigue || 'medium'
365
+ };
366
+
367
+ await writeJson(paths.profile, { ...profile, learningState, updatedAt: new Date().toISOString() });
368
+ }