@ggboy123/dsh-paper-reader 0.1.0

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/dist/preset.js ADDED
@@ -0,0 +1,31 @@
1
+ // preset.ts — 把插件自带的「论文伴读」agent preset 安装到 dsh 用户预设根。
2
+ // 纯函数模块,不依赖 Cordis。dsh 的 preset 发现是每次实时扫描
3
+ // ($DSH_HOME/.agent-presets/<id>/,includeUserRoot 默认开),拷贝到位即生效,免重启。
4
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { dshHome } from "./library.js";
7
+ export const PAPER_PRESET_ID = 'paper-reader';
8
+ /**
9
+ * 把 presets/paper-reader/{preset.yml,agent.cordis.yml} 同步到
10
+ * <dshHome>/.agent-presets/paper-reader/。幂等:内容一致不写盘。
11
+ * 返回安装目录;写失败(权限等)返回 null,调用方回退到默认 preset。
12
+ */
13
+ export async function installPaperPreset(packageRoot) {
14
+ const src = new URL('presets/paper-reader/', packageRoot);
15
+ const destDir = join(dshHome(), '.agent-presets', PAPER_PRESET_ID);
16
+ try {
17
+ await mkdir(destDir, { recursive: true });
18
+ for (const file of ['preset.yml', 'agent.cordis.yml']) {
19
+ const content = await readFile(new URL(file, src));
20
+ const dest = join(destDir, file);
21
+ const existing = await readFile(dest).catch(() => null);
22
+ if (existing === null || !existing.equals(content))
23
+ await writeFile(dest, content);
24
+ }
25
+ return destDir;
26
+ }
27
+ catch (err) {
28
+ console.warn('[dsh-paper-reader] agent preset 安装失败(回退默认 preset):', err);
29
+ return null;
30
+ }
31
+ }
package/dist/search.js ADDED
@@ -0,0 +1,98 @@
1
+ // search.ts — 长文档分段检索(纯函数,不依赖 Cordis)。
2
+ // 移植 pdfqa 的 chunkText + searchPaper,新增:片段 → 页码映射(经 pages.json 偏移表)。
3
+ /** 按空行分段后打包成 ~size 字符的片段(忠实移植 pdfqa chunkText)。 */
4
+ export function chunkText(text, size = 1500) {
5
+ const paras = text.split('\n\n');
6
+ const chunks = [];
7
+ let cur = '';
8
+ let curStart = 0;
9
+ // offset 追踪:每段在原文中的起点(paras 之间由 \n\n 连接)
10
+ let offset = 0;
11
+ const flush = (end) => {
12
+ if (cur.length > 0)
13
+ chunks.push({ index: chunks.length + 1, start: curStart, end, text: cur });
14
+ cur = '';
15
+ };
16
+ for (const raw of paras) {
17
+ const p = raw.trim();
18
+ const pStart = offset + (raw.length - raw.trimStart().length);
19
+ offset += raw.length + 2; // +2 为 \n\n
20
+ if (p === '')
21
+ continue;
22
+ if (cur.length > 0 && cur.length + p.length > size)
23
+ flush(pStart - 2);
24
+ // 单段超过 size 的硬切
25
+ let rest = p;
26
+ let restStart = pStart;
27
+ while (rest.length > size) {
28
+ if (cur.length > 0)
29
+ flush(restStart);
30
+ chunks.push({ index: chunks.length + 1, start: restStart, end: restStart + size, text: rest.slice(0, size) });
31
+ rest = rest.slice(size);
32
+ restStart += size;
33
+ }
34
+ if (rest !== '') {
35
+ if (cur.length > 0)
36
+ cur += '\n\n';
37
+ else
38
+ curStart = restStart;
39
+ cur += rest;
40
+ }
41
+ }
42
+ flush(text.length);
43
+ return chunks;
44
+ }
45
+ /** 字符位置 → 页码。无偏移表返回 null;跨页片段返回起始页。 */
46
+ export function pageForOffset(pages, pos) {
47
+ if (!pages || pages.length === 0)
48
+ return null;
49
+ let best;
50
+ for (const span of pages) {
51
+ if (pos >= span.start && pos < span.end)
52
+ return span.page;
53
+ if (!best || span.start <= pos)
54
+ best = span;
55
+ }
56
+ return best?.page ?? null;
57
+ }
58
+ /** 关键词检索:按词项出现频次 × 词长打分,top-k 后按原文顺序返回(移植 pdfqa)。 */
59
+ export function searchChunks(chunks, pages, query, k = 5) {
60
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
61
+ const hits = [];
62
+ for (const chunk of chunks) {
63
+ const lc = chunk.text.toLowerCase();
64
+ let score = 0;
65
+ for (const t of terms) {
66
+ score += countOccurrences(lc, t) * t.length; // 长词权重高
67
+ }
68
+ if (score > 0)
69
+ hits.push({ chunk, score, page: pageForOffset(pages, chunk.start) });
70
+ }
71
+ hits.sort((a, b) => b.score - a.score);
72
+ const top = hits.slice(0, k);
73
+ top.sort((a, b) => a.chunk.index - b.chunk.index); // 按原文顺序返回
74
+ return top;
75
+ }
76
+ function countOccurrences(haystack, needle) {
77
+ if (needle === '')
78
+ return 0;
79
+ let n = 0;
80
+ let i = haystack.indexOf(needle);
81
+ while (i !== -1) {
82
+ n++;
83
+ i = haystack.indexOf(needle, i + needle.length);
84
+ }
85
+ return n;
86
+ }
87
+ /** 格式化检索结果给模型消费(带页码 + 片段编号)。 */
88
+ export function formatHits(hits, totalChunks, query) {
89
+ if (hits.length === 0) {
90
+ return `没有找到与 "${query}" 相关的片段,请换关键词(建议用英文术语或章节号)`;
91
+ }
92
+ const parts = [`共 ${totalChunks} 个片段,以下按原文顺序返回最相关的 ${hits.length} 个:\n`];
93
+ for (const h of hits) {
94
+ const where = h.page != null ? `(第 ${h.page} 页)` : '';
95
+ parts.push(`=== 片段 ${h.chunk.index}/${totalChunks}${where} ===\n${h.chunk.text}\n`);
96
+ }
97
+ return parts.join('\n');
98
+ }
package/dist/study.js ADDED
@@ -0,0 +1,100 @@
1
+ // study.ts — 学习档案(纯函数,不依赖 Cordis)。
2
+ // 每篇论文一个 <文献>.study.json,存在文献旁边:阅读计划(分步状态)+ 历次检验分数。
3
+ // 这是结构化状态(教学进度),不是会话流水——dsh 的会话存储替代不了,agent 经工具读写。
4
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import { dirname } from 'node:path';
6
+ export function studyPathFor(ref) {
7
+ return ref.pdfPath.slice(0, -4) + '.study.json';
8
+ }
9
+ /** 读学习档案;不存在或损坏返回 null。 */
10
+ export async function readStudy(ref) {
11
+ try {
12
+ const raw = await readFile(studyPathFor(ref), 'utf8');
13
+ const data = JSON.parse(raw);
14
+ if (!Array.isArray(data.plan) || !Array.isArray(data.quizzes))
15
+ return null;
16
+ return data;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ async function writeStudy(ref, record) {
23
+ const file = studyPathFor(ref);
24
+ await mkdir(dirname(file), { recursive: true });
25
+ await writeFile(file, JSON.stringify(record, null, 2) + '\n', 'utf8');
26
+ return file;
27
+ }
28
+ /** 制定/重置阅读计划:第一步置为 current,其余 pending。 */
29
+ export async function setPlan(ref, steps) {
30
+ if (steps.length === 0)
31
+ throw new Error('计划不能为空');
32
+ const prev = await readStudy(ref);
33
+ const record = {
34
+ paper: `${ref.topic}/${ref.name}`,
35
+ plan: steps.map((title, i) => ({ id: i + 1, title, status: i === 0 ? 'current' : 'pending' })),
36
+ quizzes: prev?.quizzes ?? [], // 重新制定计划不清历史成绩
37
+ };
38
+ await writeStudy(ref, record);
39
+ return record;
40
+ }
41
+ /** 完成某一步(可附点评),并把下一步置为 current。 */
42
+ export async function completeStep(ref, stepId, note) {
43
+ const record = await readStudy(ref);
44
+ if (!record)
45
+ throw new Error('还没有学习计划,先用 action=set_plan 制定');
46
+ const step = record.plan.find((s) => s.id === stepId);
47
+ if (!step)
48
+ throw new Error(`计划里没有第 ${stepId} 步(共 ${record.plan.length} 步)`);
49
+ step.status = 'done';
50
+ if (note)
51
+ step.note = note;
52
+ // 归一化:current 只能有一个(第一个未完成的步骤),其余一律 pending。
53
+ // 补记/乱序完成时否则会出现多个 current(先 complete 2 再 complete 1:1 曾滞留 current)。
54
+ let firstOpen = true;
55
+ for (const s of record.plan) {
56
+ if (s.status === 'done')
57
+ continue;
58
+ if (firstOpen) {
59
+ s.status = 'current';
60
+ firstOpen = false;
61
+ }
62
+ else
63
+ s.status = 'pending';
64
+ }
65
+ await writeStudy(ref, record);
66
+ return record;
67
+ }
68
+ /** 记录一次检验成绩。 */
69
+ export async function recordQuiz(ref, score, total, summary) {
70
+ if (total <= 0 || score < 0 || score > total)
71
+ throw new Error(`分数不合法:${score}/${total}`);
72
+ const record = (await readStudy(ref)) ?? { paper: `${ref.topic}/${ref.name}`, plan: [], quizzes: [] };
73
+ record.quizzes.push({ time: new Date().toISOString(), score, total, ...(summary ? { summary } : {}) });
74
+ await writeStudy(ref, record);
75
+ return record;
76
+ }
77
+ /** 给老师/用户看的进度摘要。 */
78
+ export function formatStudy(record) {
79
+ if (!record)
80
+ return '还没有学习记录。';
81
+ const lines = [];
82
+ if (record.plan.length) {
83
+ lines.push('阅读计划:');
84
+ for (const s of record.plan) {
85
+ const mark = s.status === 'done' ? '✅' : s.status === 'current' ? '▶️' : '⬜';
86
+ lines.push(` ${mark} ${s.id}. ${s.title}${s.note ? `(${s.note})` : ''}`);
87
+ }
88
+ }
89
+ else {
90
+ lines.push('(尚未制定阅读计划)');
91
+ }
92
+ if (record.quizzes.length) {
93
+ lines.push('检验记录:');
94
+ for (const q of record.quizzes) {
95
+ const pct = Math.round((q.score / q.total) * 100);
96
+ lines.push(` · ${q.time.slice(0, 16).replace('T', ' ')} — ${q.score}/${q.total}(${pct} 分)${q.summary ? `:${q.summary}` : ''}`);
97
+ }
98
+ }
99
+ return lines.join('\n');
100
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,307 @@
1
+ // tools.ts — 工具注册(Cordis 壳;核心逻辑在 library/transcribe/search 纯函数里)。
2
+ import { defineTool } from '@deepseek-ai/dsh-tools';
3
+ import { listPapers, listTopics, resolveDataDir, resolvePaper } from "./library.js";
4
+ import { transcribePaper, readTranscript } from "./transcribe.js";
5
+ import { chunkText, searchChunks, formatHits } from "./search.js";
6
+ import { completeStep, formatStudy, readStudy, recordQuiz, setPlan } from "./study.js";
7
+ const locateParams = {
8
+ path: { type: 'string', description: 'PDF 绝对路径。与 topic+name 二选一。' },
9
+ topic: { type: 'string', description: '专题名(文献库一级目录)。' },
10
+ name: { type: 'string', description: '文献名(不带 .pdf 后缀)。省略时自动使用当前会话绑定的论文(伴读会话),只有查询别的论文才需要显式指定。' },
11
+ };
12
+ function locate(dataDir, args, exec) {
13
+ const refArgs = {};
14
+ if (args.path)
15
+ refArgs.path = args.path;
16
+ if (args.topic)
17
+ refArgs.topic = args.topic;
18
+ if (args.name)
19
+ refArgs.name = args.name;
20
+ // 未指定论文时回退到「当前会话绑定的论文」:伴读会话 id 形如 dpr-<base64url(topic/name)>-N,
21
+ // 工具执行上下文里的 agent.id 就是会话 id。直接在输入框提问(不经选中即问)时靠它定位。
22
+ if (!refArgs.path && !refArgs.name) {
23
+ const paper = paperFromSessionId(exec?.agent?.id);
24
+ if (paper)
25
+ return resolvePaper(dataDir, { topic: paper.topic, name: paper.name });
26
+ }
27
+ return resolvePaper(dataDir, refArgs);
28
+ }
29
+ /** 从伴读会话 id 反解论文(dpr-<base64url(topic/name)>[-N]);非伴读会话返回 null。 */
30
+ export function paperFromSessionId(sessionId) {
31
+ if (!sessionId?.startsWith('dpr-'))
32
+ return null;
33
+ try {
34
+ const body = sessionId.slice(4).replace(/-\d+$/, '');
35
+ const decoded = Buffer.from(body, 'base64url').toString('utf8');
36
+ const sep = decoded.indexOf('/');
37
+ if (sep <= 0 || sep === decoded.length - 1)
38
+ return null;
39
+ return { topic: decoded.slice(0, sep), name: decoded.slice(sep + 1) };
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ export function registerTools(ctx, config) {
46
+ const dataDir = resolveDataDir(config.dataDir);
47
+ ctx.tools.register(defineTool({
48
+ name: 'list_papers',
49
+ description: '列出文献库里的专题和文献。当用户提到某篇论文但你不知道它是否在库中、或用户问"我有哪些论文"时使用。',
50
+ parameters: {
51
+ topic: { type: 'string', description: '只看某个专题;省略则列出全部。' },
52
+ },
53
+ output: {
54
+ schema: {
55
+ type: 'object',
56
+ properties: {
57
+ dataDir: { type: 'string', required: true, description: '文献库根目录。' },
58
+ topics: {
59
+ type: 'array',
60
+ required: true,
61
+ items: { type: 'string' },
62
+ description: '专题列表。',
63
+ },
64
+ papers: {
65
+ type: 'array',
66
+ required: true,
67
+ items: { type: 'string', description: '形如 专题/文献名' },
68
+ description: '文献列表。',
69
+ },
70
+ },
71
+ additionalProperties: false,
72
+ },
73
+ render: (_args, value) => [
74
+ {
75
+ type: 'text',
76
+ text: value.papers.length === 0
77
+ ? `文献库为空(${value.dataDir})。把 PDF 放进 <专题>/ 目录即可。`
78
+ : `文献库(${value.dataDir}):\n${value.papers.map((p) => `- ${p}`).join('\n')}`,
79
+ },
80
+ ],
81
+ },
82
+ execute: async (args, exec) => {
83
+ const papers = listPapers(dataDir, args.topic).map((p) => `${p.topic}/${p.name}`);
84
+ return { dataDir, topics: listTopics(dataDir), papers };
85
+ },
86
+ }));
87
+ ctx.tools.register(defineTool({
88
+ name: 'transcribe_pdf',
89
+ description: '把一篇 PDF 论文转录为纯文本(本地 PyMuPDF 提取,带页码索引),结果落盘缓存。' +
90
+ '读一篇新论文的第一步:先转录,再用 search_paper 检索。已有缓存时秒回。',
91
+ parameters: {
92
+ ...locateParams,
93
+ force: { type: 'boolean', description: '忽略缓存重新转录。' },
94
+ },
95
+ output: {
96
+ schema: {
97
+ type: 'object',
98
+ properties: {
99
+ paper: { type: 'string', required: true },
100
+ txtPath: { type: 'string', required: true, description: '转录文本路径。' },
101
+ chars: { type: 'integer', required: true },
102
+ pageCount: { type: 'integer', required: true },
103
+ hasPageIndex: { type: 'boolean', required: true, description: '是否有页码索引(检索结果能否带页码)。' },
104
+ source: { type: 'string', required: true, enum: ['cache', 'local'] },
105
+ },
106
+ additionalProperties: false,
107
+ },
108
+ render: (_args, value) => [
109
+ {
110
+ type: 'text',
111
+ text: `已转录《${value.paper}》:${value.chars} 字符,${value.pageCount} 页` +
112
+ `(${value.source === 'cache' ? '缓存复用' : '本地提取'}${value.hasPageIndex ? ',含页码索引' : ',无页码索引'})。` +
113
+ `接下来可用 search_paper 检索具体内容。`,
114
+ },
115
+ ],
116
+ },
117
+ execute: async (args, exec) => {
118
+ const ref = locate(dataDir, args, exec);
119
+ const t = await transcribePaper(ref, { force: args.force === true });
120
+ return {
121
+ paper: `${ref.topic}/${ref.name}`,
122
+ txtPath: ref.txtPath,
123
+ chars: t.chars,
124
+ pageCount: t.pageCount,
125
+ hasPageIndex: t.pages.length > 0,
126
+ source: t.source,
127
+ };
128
+ },
129
+ }));
130
+ ctx.tools.register(defineTool({
131
+ name: 'search_paper',
132
+ description: '在论文全文中按关键词检索相关片段,返回带页码的原文片段。' +
133
+ '当需要论文的具体内容(定义、算法步骤、章节细节、数据)时使用,基于检索到的原文回答并注明页码。' +
134
+ '关键词建议:用论文中的英文术语、章节号(如 "Section 3.1")或概念名,不要用完整长句;找不到时换同义词或更短的词重试。' +
135
+ '论文未转录时会自动先转录。',
136
+ parameters: {
137
+ query: { type: 'string', required: true, description: '检索关键词,如 "backup task" 或 "locality"。' },
138
+ k: { type: 'integer', description: '返回片段数量,默认 5,最多 8。' },
139
+ ...locateParams,
140
+ },
141
+ output: {
142
+ schema: {
143
+ type: 'object',
144
+ properties: {
145
+ paper: { type: 'string', required: true },
146
+ query: { type: 'string', required: true },
147
+ totalChunks: { type: 'integer', required: true },
148
+ hits: {
149
+ type: 'array',
150
+ required: true,
151
+ items: {
152
+ type: 'object',
153
+ properties: {
154
+ chunk: { type: 'integer', required: true },
155
+ page: {
156
+ oneOf: [{ type: 'integer' }, { type: 'null' }],
157
+ required: true,
158
+ description: '片段起始页码;无页码索引时为 null。',
159
+ },
160
+ text: { type: 'string', required: true },
161
+ },
162
+ additionalProperties: false,
163
+ },
164
+ },
165
+ },
166
+ additionalProperties: false,
167
+ },
168
+ render: (_args, value) => [
169
+ {
170
+ type: 'text',
171
+ text: formatHits(value.hits.map((h) => ({
172
+ chunk: { index: h.chunk, start: 0, end: 0, text: h.text },
173
+ score: 0,
174
+ page: h.page,
175
+ })), value.totalChunks, value.query),
176
+ },
177
+ ],
178
+ },
179
+ execute: async (args, exec) => {
180
+ const ref = locate(dataDir, args, exec);
181
+ let cached = await readTranscript(ref);
182
+ if (!cached) {
183
+ await transcribePaper(ref);
184
+ cached = await readTranscript(ref);
185
+ }
186
+ if (!cached)
187
+ throw new Error('转录失败,无法检索');
188
+ const chunks = chunkText(cached.text, 1500);
189
+ const k = Math.max(1, Math.min(args.k ?? 5, 8));
190
+ const hits = searchChunks(chunks, cached.pages, args.query, k);
191
+ return {
192
+ paper: `${ref.topic}/${ref.name}`,
193
+ query: args.query,
194
+ totalChunks: chunks.length,
195
+ hits: hits.map((h) => ({ chunk: h.chunk.index, page: h.page, text: h.chunk.text })),
196
+ };
197
+ },
198
+ }));
199
+ ctx.tools.register(defineTool({
200
+ name: 'study_progress',
201
+ description: '读取一篇论文的学习档案:分步阅读计划(每步状态)和历次检验成绩。' +
202
+ '开始伴读、或用户想继续学习/查看进度时先调用;没有记录时返回空,此时应制定计划(study_update 的 set_plan)。',
203
+ parameters: { ...locateParams },
204
+ output: {
205
+ schema: {
206
+ type: 'object',
207
+ properties: {
208
+ paper: { type: 'string', required: true },
209
+ hasRecord: { type: 'boolean', required: true },
210
+ plan: {
211
+ type: 'array',
212
+ required: true,
213
+ items: {
214
+ type: 'object',
215
+ properties: {
216
+ id: { type: 'integer', required: true },
217
+ title: { type: 'string', required: true },
218
+ status: { type: 'string', required: true, enum: ['pending', 'current', 'done'] },
219
+ note: { type: 'string' },
220
+ },
221
+ additionalProperties: false,
222
+ },
223
+ },
224
+ quizzes: {
225
+ type: 'array',
226
+ required: true,
227
+ items: {
228
+ type: 'object',
229
+ properties: {
230
+ time: { type: 'string', required: true },
231
+ score: { type: 'integer', required: true },
232
+ total: { type: 'integer', required: true },
233
+ summary: { type: 'string' },
234
+ },
235
+ additionalProperties: false,
236
+ },
237
+ },
238
+ },
239
+ additionalProperties: false,
240
+ },
241
+ render: (_args, value) => [
242
+ {
243
+ type: 'text',
244
+ text: `《${value.paper}》学习档案:\n` + formatStudy(value.hasRecord ? { paper: value.paper, plan: value.plan, quizzes: value.quizzes } : null),
245
+ },
246
+ ],
247
+ },
248
+ execute: async (args, exec) => {
249
+ const ref = locate(dataDir, args, exec);
250
+ const record = await readStudy(ref);
251
+ return {
252
+ paper: `${ref.topic}/${ref.name}`,
253
+ hasRecord: record !== null,
254
+ plan: record?.plan ?? [],
255
+ quizzes: record?.quizzes ?? [],
256
+ };
257
+ },
258
+ }));
259
+ ctx.tools.register(defineTool({
260
+ name: 'study_update',
261
+ description: '更新一篇论文的学习档案(老师用):' +
262
+ 'set_plan=制定阅读计划(steps 为 3~6 个分步标题,会重置计划但保留历史成绩);' +
263
+ 'complete_step=学生完成某一步(step_id + 可选 note 点评,自动推进到下一步);' +
264
+ 'record_quiz=记录一次检验成绩(score/total + 可选 summary 薄弱点小结)。',
265
+ parameters: {
266
+ action: { type: 'string', required: true, enum: ['set_plan', 'complete_step', 'record_quiz'] },
267
+ steps: { type: 'array', items: { type: 'string' }, description: 'set_plan 用:分步标题列表,如 "读摘要与引言(第 1 页),说出论文要解决的问题"。' },
268
+ step_id: { type: 'integer', description: 'complete_step 用:完成的步骤号。' },
269
+ note: { type: 'string', description: 'complete_step 用:对学生这一步表现的点评。' },
270
+ score: { type: 'integer', description: 'record_quiz 用:得分。' },
271
+ total: { type: 'integer', description: 'record_quiz 用:满分。' },
272
+ summary: { type: 'string', description: 'record_quiz 用:本次检验小结(考点/薄弱点)。' },
273
+ ...locateParams,
274
+ },
275
+ output: {
276
+ schema: {
277
+ type: 'object',
278
+ properties: {
279
+ paper: { type: 'string', required: true },
280
+ summary: { type: 'string', required: true, description: '更新后的档案摘要。' },
281
+ },
282
+ additionalProperties: false,
283
+ },
284
+ render: (_args, value) => [{ type: 'text', text: `《${value.paper}》档案已更新:\n${value.summary}` }],
285
+ },
286
+ execute: async (args, exec) => {
287
+ const ref = locate(dataDir, args, exec);
288
+ let record;
289
+ if (args.action === 'set_plan') {
290
+ if (!args.steps?.length)
291
+ throw new Error('set_plan 需要 steps');
292
+ record = await setPlan(ref, args.steps);
293
+ }
294
+ else if (args.action === 'complete_step') {
295
+ if (!args.step_id)
296
+ throw new Error('complete_step 需要 step_id');
297
+ record = await completeStep(ref, args.step_id, args.note);
298
+ }
299
+ else {
300
+ if (args.score === undefined || args.total === undefined)
301
+ throw new Error('record_quiz 需要 score 和 total');
302
+ record = await recordQuiz(ref, args.score, args.total, args.summary);
303
+ }
304
+ return { paper: `${ref.topic}/${ref.name}`, summary: formatStudy(record) };
305
+ },
306
+ }));
307
+ }