@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/AGENTS.md +1 -0
- package/bin/math-ati.js +136 -5
- package/dist/assets/{index--Um9OfFu.css → index-DOg8CQsE.css} +1 -1
- package/dist/assets/index-DyfeTKmg.js +22 -0
- package/dist/index.html +2 -2
- package/package.json +7 -4
- package/prompts/grading.system.md +3 -1
- package/prompts/knowledge-structure.system.md +75 -0
- package/prompts/knowledge-summarize.system.md +13 -1
- package/prompts/pdf-grading.system.md +4 -1
- package/prompts/pdf-recheck.system.md +2 -0
- package/prompts/practice-answers.system.md +154 -0
- package/prompts/practice-coverage-repair.system.md +112 -0
- package/prompts/practice-generate.system.md +45 -5
- package/prompts/practice-rules.md +61 -0
- package/server/fileStore.js +9 -2
- package/server/index.js +48 -0
- package/server/knowledgeExtractor.js +218 -59
- package/server/knowledgeFeedback.js +69 -0
- package/server/practiceGenerator.js +76 -82
- package/server/promptStore.js +14 -0
- package/dist/assets/index-CS-PgjYi.js +0 -22
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 ||
|
|
17
|
-
const KNOWLEDGE_SUMMARY_TIMEOUT_MS = Number(process.env.KNOWLEDGE_EXTRACT_SUMMARY_TIMEOUT_MS ||
|
|
18
|
-
const KNOWLEDGE_PAGE_RETRIES = Number(process.env.KNOWLEDGE_EXTRACT_PAGE_RETRIES ||
|
|
19
|
-
const KNOWLEDGE_SUMMARY_RETRIES = Number(process.env.KNOWLEDGE_EXTRACT_SUMMARY_RETRIES ||
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
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
|
-
|
|
660
|
-
`知识点分组 ${chunkIndex + 1}/${chunkCount}
|
|
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 =
|
|
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
|
-
|
|
794
|
-
|
|
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
|
+
}
|