dsh-thesis 1.0.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/DESIGN.md +390 -0
- package/LICENSE +21 -0
- package/NOTICE.md +10 -0
- package/README.md +253 -0
- package/cordis.patch.yml +17 -0
- package/docs/host-api.md +640 -0
- package/lib/codewalk/commands.d.ts +23 -0
- package/lib/codewalk/commands.js +168 -0
- package/lib/codewalk/engine.d.ts +108 -0
- package/lib/codewalk/engine.js +449 -0
- package/lib/codewalk/errors.d.ts +31 -0
- package/lib/codewalk/errors.js +20 -0
- package/lib/codewalk/fs-types.d.ts +34 -0
- package/lib/codewalk/fs-types.js +10 -0
- package/lib/codewalk/gate.d.ts +30 -0
- package/lib/codewalk/gate.js +60 -0
- package/lib/codewalk/host-types.d.ts +198 -0
- package/lib/codewalk/host-types.js +11 -0
- package/lib/codewalk/prestep.d.ts +17 -0
- package/lib/codewalk/prestep.js +62 -0
- package/lib/codewalk/register.d.ts +23 -0
- package/lib/codewalk/register.js +31 -0
- package/lib/codewalk/skill.d.ts +15 -0
- package/lib/codewalk/skill.js +57 -0
- package/lib/codewalk/tools.d.ts +18 -0
- package/lib/codewalk/tools.js +314 -0
- package/lib/codewalk/types.d.ts +151 -0
- package/lib/codewalk/types.js +9 -0
- package/lib/commands.d.ts +33 -0
- package/lib/commands.js +60 -0
- package/lib/config.d.ts +125 -0
- package/lib/config.js +139 -0
- package/lib/dedup/commands.d.ts +21 -0
- package/lib/dedup/commands.js +93 -0
- package/lib/dedup/index.d.ts +75 -0
- package/lib/dedup/index.js +331 -0
- package/lib/dedup/normalize.d.ts +125 -0
- package/lib/dedup/normalize.js +329 -0
- package/lib/dedup/report.d.ts +114 -0
- package/lib/dedup/report.js +309 -0
- package/lib/dedup/rewrite.d.ts +71 -0
- package/lib/dedup/rewrite.js +228 -0
- package/lib/dedup/similarity.d.ts +169 -0
- package/lib/dedup/similarity.js +323 -0
- package/lib/gates/dangerous.d.ts +22 -0
- package/lib/gates/dangerous.js +47 -0
- package/lib/gates/detect.d.ts +54 -0
- package/lib/gates/detect.js +106 -0
- package/lib/gates/messages.d.ts +39 -0
- package/lib/gates/messages.js +74 -0
- package/lib/gates/prompt.d.ts +21 -0
- package/lib/gates/prompt.js +56 -0
- package/lib/gates/writing-intent.d.ts +49 -0
- package/lib/gates/writing-intent.js +68 -0
- package/lib/index.d.ts +36 -0
- package/lib/index.js +76 -0
- package/lib/ingest/code.d.ts +71 -0
- package/lib/ingest/code.js +356 -0
- package/lib/ingest/commands.d.ts +23 -0
- package/lib/ingest/commands.js +74 -0
- package/lib/ingest/docx.d.ts +29 -0
- package/lib/ingest/docx.js +92 -0
- package/lib/ingest/index.d.ts +179 -0
- package/lib/ingest/index.js +640 -0
- package/lib/ingest/ooxml.d.ts +58 -0
- package/lib/ingest/ooxml.js +393 -0
- package/lib/ingest/pdf.d.ts +86 -0
- package/lib/ingest/pdf.js +1016 -0
- package/lib/ingest/pptx.d.ts +32 -0
- package/lib/ingest/pptx.js +116 -0
- package/lib/ingest/text.d.ts +25 -0
- package/lib/ingest/text.js +180 -0
- package/lib/ingest/types.d.ts +48 -0
- package/lib/ingest/types.js +47 -0
- package/lib/ingest/xlsx.d.ts +72 -0
- package/lib/ingest/xlsx.js +411 -0
- package/lib/ingest/zip.d.ts +76 -0
- package/lib/ingest/zip.js +393 -0
- package/lib/intake/definitions.d.ts +65 -0
- package/lib/intake/definitions.js +42 -0
- package/lib/intake/gate-rules.d.ts +57 -0
- package/lib/intake/gate-rules.js +118 -0
- package/lib/intake/gate.d.ts +24 -0
- package/lib/intake/gate.js +86 -0
- package/lib/intake/index.d.ts +76 -0
- package/lib/intake/index.js +468 -0
- package/lib/intake/questions.d.ts +169 -0
- package/lib/intake/questions.js +720 -0
- package/lib/intake/spec.d.ts +66 -0
- package/lib/intake/spec.js +221 -0
- package/lib/intake/state.d.ts +192 -0
- package/lib/intake/state.js +420 -0
- package/lib/memory/recall.d.ts +69 -0
- package/lib/memory/recall.js +177 -0
- package/lib/memory/register.d.ts +37 -0
- package/lib/memory/register.js +52 -0
- package/lib/memory/service.d.ts +39 -0
- package/lib/memory/service.js +39 -0
- package/lib/memory/store.d.ts +45 -0
- package/lib/memory/store.js +123 -0
- package/lib/memory/tools.d.ts +19 -0
- package/lib/memory/tools.js +195 -0
- package/lib/memory/types.d.ts +55 -0
- package/lib/memory/types.js +6 -0
- package/lib/paper/commands.d.ts +13 -0
- package/lib/paper/commands.js +84 -0
- package/lib/paper/lib/aicheck.d.ts +38 -0
- package/lib/paper/lib/aicheck.js +241 -0
- package/lib/paper/lib/bibtex.d.ts +21 -0
- package/lib/paper/lib/bibtex.js +114 -0
- package/lib/paper/lib/check.d.ts +32 -0
- package/lib/paper/lib/check.js +215 -0
- package/lib/paper/lib/defense.d.ts +48 -0
- package/lib/paper/lib/defense.js +220 -0
- package/lib/paper/lib/docx.d.ts +35 -0
- package/lib/paper/lib/docx.js +213 -0
- package/lib/paper/lib/layout.d.ts +41 -0
- package/lib/paper/lib/layout.js +572 -0
- package/lib/paper/lib/ledger.d.ts +112 -0
- package/lib/paper/lib/ledger.js +349 -0
- package/lib/paper/lib/lit-api.d.ts +59 -0
- package/lib/paper/lib/lit-api.js +296 -0
- package/lib/paper/lib/lit-cache.d.ts +32 -0
- package/lib/paper/lib/lit-cache.js +91 -0
- package/lib/paper/lib/project.d.ts +29 -0
- package/lib/paper/lib/project.js +93 -0
- package/lib/paper/lib/review.d.ts +37 -0
- package/lib/paper/lib/review.js +194 -0
- package/lib/paper/lib/zip.d.ts +14 -0
- package/lib/paper/lib/zip.js +124 -0
- package/lib/paper/register.d.ts +20 -0
- package/lib/paper/register.js +183 -0
- package/lib/paper/tools/aicheck.d.ts +6 -0
- package/lib/paper/tools/aicheck.js +38 -0
- package/lib/paper/tools/build.d.ts +27 -0
- package/lib/paper/tools/build.js +166 -0
- package/lib/paper/tools/check.d.ts +7 -0
- package/lib/paper/tools/check.js +79 -0
- package/lib/paper/tools/decision.d.ts +13 -0
- package/lib/paper/tools/decision.js +53 -0
- package/lib/paper/tools/defense.d.ts +11 -0
- package/lib/paper/tools/defense.js +74 -0
- package/lib/paper/tools/init.d.ts +23 -0
- package/lib/paper/tools/init.js +99 -0
- package/lib/paper/tools/lit.d.ts +27 -0
- package/lib/paper/tools/lit.js +236 -0
- package/lib/paper/tools/progress.d.ts +18 -0
- package/lib/paper/tools/progress.js +77 -0
- package/lib/paper/tools/review.d.ts +11 -0
- package/lib/paper/tools/review.js +41 -0
- package/lib/ppt/actions.d.ts +97 -0
- package/lib/ppt/actions.js +263 -0
- package/lib/ppt/check.d.ts +64 -0
- package/lib/ppt/check.js +185 -0
- package/lib/ppt/commands.d.ts +28 -0
- package/lib/ppt/commands.js +71 -0
- package/lib/ppt/convert.d.ts +146 -0
- package/lib/ppt/convert.js +423 -0
- package/lib/ppt/index.d.ts +45 -0
- package/lib/ppt/index.js +57 -0
- package/lib/ppt/marp.d.ts +74 -0
- package/lib/ppt/marp.js +165 -0
- package/lib/ppt/outline.d.ts +206 -0
- package/lib/ppt/outline.js +695 -0
- package/lib/shared/define-tool.d.ts +77 -0
- package/lib/shared/define-tool.js +235 -0
- package/lib/shared/fs-errors.d.ts +50 -0
- package/lib/shared/fs-errors.js +90 -0
- package/lib/shared/index.d.ts +31 -0
- package/lib/shared/index.js +32 -0
- package/lib/shared/spawn.d.ts +38 -0
- package/lib/shared/spawn.js +50 -0
- package/lib/shared/text.d.ts +14 -0
- package/lib/shared/text.js +26 -0
- package/package.json +82 -0
- package/skills/code-walkthrough/SKILL.md +94 -0
- package/skills/code-walkthrough/references/examples.md +129 -0
- package/skills/paper-dedup/SKILL.md +58 -0
- package/skills/paper-intake/SKILL.md +64 -0
- package/skills/thesis-citation/SKILL.md +67 -0
- package/skills/thesis-defense/SKILL.md +87 -0
- package/skills/thesis-eng-design/SKILL.md +97 -0
- package/skills/thesis-inquiry/SKILL.md +55 -0
- package/skills/thesis-inquiry/references/intent-spec-template.md +39 -0
- package/skills/thesis-literature/SKILL.md +56 -0
- package/skills/thesis-opener/SKILL.md +77 -0
- package/skills/thesis-pipeline/SKILL.md +121 -0
- package/skills/thesis-writing/SKILL.md +86 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `thesis_intake`:材料到手后的逐题追问(一次一问 → 可证伪意图规格)。
|
|
3
|
+
*
|
|
4
|
+
* 产品链路:`thesis_ingest` 把学校模板/论文要求/实验数据/代码/参考文献读进工作区 →
|
|
5
|
+
* 本工具**逐题追问**,把模糊要求变成可检查的条款 → 落盘 `00-管理/意图规格.md` →
|
|
6
|
+
* 后续写作/格式检查/降重/PPT/答辩**只依据该规格**。
|
|
7
|
+
*
|
|
8
|
+
* 三条硬要求(全部在代码里强制,不靠模型自觉):
|
|
9
|
+
* 1. **一次只问一个**:任何 action 的返回里最多出现一个问题;
|
|
10
|
+
* 2. **规格永远最新**:每次 `answer`/`skip` 之后立即重绘并原子落盘规格;
|
|
11
|
+
* 3. **必答项不齐不许 `done`**:`done` 会拒绝并列出缺口,绝不放水。
|
|
12
|
+
*
|
|
13
|
+
* 本文件是**执行层 + 装配入口**:执行层零 `@deepseek-ai/*` 运行时导入(只借用它们的类型),
|
|
14
|
+
* 因此测试可以直接用假 fs 驱动 {@link runIntake};`registerIntake` 只在装配时调用宿主能力。
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-thesis/intake
|
|
17
|
+
*/
|
|
18
|
+
import { definePaperTool, sessionCwd, textOutput } from "../shared/index.js";
|
|
19
|
+
import { INTAKE_ACTIONS, intakeToolDefinition, } from "./definitions.js";
|
|
20
|
+
import { blockingRequired, clampQueue, listRequired, makeContext, materialInsight, nextQuestion, questionById, questionQueue, QUESTIONS, } from "./questions.js";
|
|
21
|
+
import { applyAnswer, applySkip, defaultState, fsIo, loadMaterials, loadStage, loadState, markAsked, saveState, setStage, } from "./state.js";
|
|
22
|
+
import { checkAnswer, writeSpec } from "./spec.js";
|
|
23
|
+
import { findThesisRoot } from "../paper/lib/project.js";
|
|
24
|
+
/** 规格默认位置(配置项缺省时使用)。 */
|
|
25
|
+
const SPEC_DEFAULT_REL = '00-管理/意图规格.md';
|
|
26
|
+
/** 找不到工作区的统一指引(不抛裸异常)。 */
|
|
27
|
+
export async function requireThesisRoot(fs, cwd, signal) {
|
|
28
|
+
return await findThesisRoot(fs, cwd, signal);
|
|
29
|
+
}
|
|
30
|
+
/** 载入追问上下文:材料清单 + 台账阶段(都读不到就退化为默认值)。 */
|
|
31
|
+
export async function loadContext(io, root) {
|
|
32
|
+
const [materials, stage] = await Promise.all([loadMaterials(io, root), loadStage(io, root)]);
|
|
33
|
+
return makeContext(materials, stage);
|
|
34
|
+
}
|
|
35
|
+
/** 规格相对路径:配置为空时回退到默认位置。 */
|
|
36
|
+
export function specRelOf(intake) {
|
|
37
|
+
const rel = typeof intake.specRel === 'string' ? intake.specRel.trim() : '';
|
|
38
|
+
return rel === '' ? SPEC_DEFAULT_REL : rel;
|
|
39
|
+
}
|
|
40
|
+
/** 读状态;不存在则建一份(`specPath` 用配置里的路径,保证后续渲染一致)。 */
|
|
41
|
+
async function loadOrInit(io, root, intake) {
|
|
42
|
+
const found = await loadState(io, root);
|
|
43
|
+
if (found !== undefined) {
|
|
44
|
+
if (found.specPath === '')
|
|
45
|
+
found.specPath = specRelOf(intake);
|
|
46
|
+
return { state: found, created: false };
|
|
47
|
+
}
|
|
48
|
+
const fresh = defaultState(new Date(), specRelOf(intake));
|
|
49
|
+
await saveState(io, root, fresh);
|
|
50
|
+
return { state: fresh, created: true };
|
|
51
|
+
}
|
|
52
|
+
/** 进度一句话:必答项已答数 + 未答 + 格式待修正。 */
|
|
53
|
+
export function progressLine(state) {
|
|
54
|
+
const required = listRequired();
|
|
55
|
+
const answered = required.filter(q => state.answers[q.id] !== undefined && state.answers[q.id]?.valid !== false);
|
|
56
|
+
const missing = required.filter(q => state.answers[q.id] === undefined);
|
|
57
|
+
const invalid = required.filter(q => state.answers[q.id]?.valid === false);
|
|
58
|
+
const parts = [`必答项 ${answered.length}/${required.length} 已答`];
|
|
59
|
+
if (missing.length > 0)
|
|
60
|
+
parts.push(`未答:${missing.map(q => q.id).join('、')}`);
|
|
61
|
+
if (invalid.length > 0)
|
|
62
|
+
parts.push(`格式待修正:${invalid.map(q => q.id).join('、')}`);
|
|
63
|
+
return parts.join(';');
|
|
64
|
+
}
|
|
65
|
+
/** 问题卡片:一次只渲染一个问题,包含 why / 影响 / 合格答案形态 / hint。 */
|
|
66
|
+
function questionCard(q, state, ctx, extra = []) {
|
|
67
|
+
const insight = materialInsight(ctx.materials);
|
|
68
|
+
const auto = insight.autoAnswers.get(q.id);
|
|
69
|
+
const rec = state.answers[q.id];
|
|
70
|
+
const lines = [];
|
|
71
|
+
if (extra.length > 0)
|
|
72
|
+
lines.push(...extra, '');
|
|
73
|
+
lines.push(`【下一个问题】${q.id} · ${q.section}`);
|
|
74
|
+
lines.push(`问:${q.ask}`);
|
|
75
|
+
lines.push(`为什么问:${q.why}`);
|
|
76
|
+
lines.push(`影响产出:${q.affects.join('、')}`);
|
|
77
|
+
lines.push(`合格答案:${q.answerShape}`);
|
|
78
|
+
if (q.hint !== undefined)
|
|
79
|
+
lines.push(`答不上来?${q.hint}`);
|
|
80
|
+
if (auto !== undefined && (rec === undefined || !rec.confirmed)) {
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push(`💡 材料里已经有答案了:${auto.value}`);
|
|
83
|
+
lines.push(`依据:${auto.evidence}`);
|
|
84
|
+
lines.push('请确认这条信息对不对(对就回「对」,不对就给出正确内容),确认后我不再重复问。');
|
|
85
|
+
}
|
|
86
|
+
if (q.required)
|
|
87
|
+
lines.push('', '⚠️ 这是必答项:不回答就无法完成追问(done 会拒绝)。');
|
|
88
|
+
lines.push('');
|
|
89
|
+
lines.push(`进度:${progressLine(state)}`);
|
|
90
|
+
lines.push(`作答:thesis_intake action=answer question_id=${q.id} answer=<你的答案>`);
|
|
91
|
+
lines.push(`答不了:thesis_intake action=skip question_id=${q.id} reason=<理由>`);
|
|
92
|
+
return lines.join('\n');
|
|
93
|
+
}
|
|
94
|
+
/** 会话内问题数达到 maxQuestions 时的收尾提示(不追问、不静默)。 */
|
|
95
|
+
function capReached(state, maxQuestions, specRel, ctx) {
|
|
96
|
+
const remaining = questionQueue(state, ctx).filter(q => state.answers[q.id] === undefined);
|
|
97
|
+
const head = remaining[0];
|
|
98
|
+
return [
|
|
99
|
+
`本次会话的追问上限(maxQuestions=${maxQuestions})已到,先停在这里,避免一次问太多。`,
|
|
100
|
+
`进度:${progressLine(state)}`,
|
|
101
|
+
head !== undefined ? `还剩 ${remaining.length} 个可问的问题(下一个是 ${head.id}:${head.ask})` : '当前没有待问的问题。',
|
|
102
|
+
'',
|
|
103
|
+
'下一步:新开一轮继续追问(同一工具再调一次即可,状态已落盘,会从当前问题接着问);',
|
|
104
|
+
`或者先看规格:thesis_intake action=spec(落盘到 ${specRel})。`,
|
|
105
|
+
'必答项齐全后:thesis_intake action=done。',
|
|
106
|
+
].join('\n');
|
|
107
|
+
}
|
|
108
|
+
/** 追问完成:报告规格位置与必答项摘要。 */
|
|
109
|
+
function completionReport(state, specRel, blockersNow) {
|
|
110
|
+
const lines = [];
|
|
111
|
+
lines.push('✅ 必答项已齐全,意图规格收敛完成。');
|
|
112
|
+
lines.push(`规格文件:${specRel}(内容指纹已在文件头标注)`);
|
|
113
|
+
lines.push(`必答项:${listRequired().length}/${listRequired().length} 已答;全部问题已答 ${Object.keys(state.answers).length}/${QUESTIONS.length}`);
|
|
114
|
+
if (blockersNow.length > 0) {
|
|
115
|
+
lines.push(`注意:仍有 ${blockersNow.length} 个必答项未通过格式校验(${blockersNow.map(q => q.id).join('、')}),请重答修正。`);
|
|
116
|
+
}
|
|
117
|
+
const skipped = state.skipped.length;
|
|
118
|
+
lines.push(skipped > 0 ? `已跳过 ${skipped} 项(不影响 done,但规格里会标注为未知):${state.skipped.join('、')}` : '没有跳过任何问题。');
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push('下一步:让写作/格式检查/降重/PPT/答辩都依据这份规格;规格有变动时重答对应问题即可自动刷新。');
|
|
121
|
+
return lines.join('\n');
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 执行一次 `thesis_intake` 调用(纯 I/O,零 cordis 依赖)。
|
|
125
|
+
*
|
|
126
|
+
* @param sessionAsked 本次会话已经问过的问题数(由调用方累加,用于 `maxQuestions` 上限)。
|
|
127
|
+
*/
|
|
128
|
+
export async function runIntake(fs, intake, args, cwd, signal, sessionAsked = 0) {
|
|
129
|
+
const action = typeof args.action === 'string' ? args.action.trim() : '';
|
|
130
|
+
if (!INTAKE_ACTIONS.includes(action)) {
|
|
131
|
+
return `未知 action:${action === '' ? '(空)' : action}。有效值:${INTAKE_ACTIONS.join(' | ')}。\n用法:先 action=start(初始化或读取状态并返回第一个问题),再对每个问题 action=answer。`;
|
|
132
|
+
}
|
|
133
|
+
const root = await requireThesisRoot(fs, cwd, signal);
|
|
134
|
+
if (root === null) {
|
|
135
|
+
return [
|
|
136
|
+
'未找到论文工作区(从当前目录向上查找 00-管理/进度台账.md 失败)。',
|
|
137
|
+
'请先创建并进入论文工作区:thesis_init root=<绝对路径> title=<题目>,然后在该目录里重试。',
|
|
138
|
+
'如果工作区已存在,请在论文仓库目录内调用本工具(工具按会话工作目录向上探测)。',
|
|
139
|
+
].join('\n');
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* I/O 全部走 `ctx.fs`(含临时文件的写入与清理)。
|
|
143
|
+
*
|
|
144
|
+
* 这里刻意不注入基于 `node:fs/promises` 的改名:`ctx.fs` 之外的真实文件写入会绕过
|
|
145
|
+
* 宿主沙箱与观测,且让假 fs 测试失真。原子语义由「同目录临时文件 + 覆盖写 + 清理临时文件」
|
|
146
|
+
* 保证;`state.ts` 的 `IntakeIo.rename` 给需要真改名的宿主留了注入点。
|
|
147
|
+
*/
|
|
148
|
+
const io = fsIo(fs, signal);
|
|
149
|
+
const specRel = specRelOf(intake);
|
|
150
|
+
const maxQuestions = Math.max(1, Math.floor(intake.maxQuestions));
|
|
151
|
+
try {
|
|
152
|
+
switch (action) {
|
|
153
|
+
case 'start': {
|
|
154
|
+
const { state, created } = await loadOrInit(io, root, intake);
|
|
155
|
+
const ctx = await loadContext(io, root);
|
|
156
|
+
const written = await writeSpec(io, root, state, ctx);
|
|
157
|
+
await saveState(io, root, state);
|
|
158
|
+
const lead = created
|
|
159
|
+
? `已初始化追问状态(.paper/intake.json)并生成规格:${written.path}`
|
|
160
|
+
: `已读取既有追问状态(可断点续问):${state.stage === 'done' ? '必答项已齐全' : '进行中'}`;
|
|
161
|
+
if (state.stage === 'done' && blockingRequired(state).length === 0) {
|
|
162
|
+
return `${lead}\n\n${completionReport(state, written.path, [])}`;
|
|
163
|
+
}
|
|
164
|
+
const q = nextQuestion(state, ctx);
|
|
165
|
+
if (q === undefined) {
|
|
166
|
+
return `${lead}\n\n本轮没有待问的问题。\n进度:${progressLine(state)}\n下一步:thesis_intake action=done(校验必答项并收尾)。`;
|
|
167
|
+
}
|
|
168
|
+
return questionCard(q, state, ctx, [lead, '一次只问一个问题——答完这个再问下一个。']);
|
|
169
|
+
}
|
|
170
|
+
case 'ask': {
|
|
171
|
+
const { state } = await loadOrInit(io, root, intake);
|
|
172
|
+
const ctx = await loadContext(io, root);
|
|
173
|
+
if (sessionAsked >= maxQuestions)
|
|
174
|
+
return capReached(state, maxQuestions, specRel, ctx);
|
|
175
|
+
const q = nextQuestion(state, ctx);
|
|
176
|
+
if (q === undefined) {
|
|
177
|
+
const blockersNow = blockingRequired(state);
|
|
178
|
+
if (blockersNow.length === 0) {
|
|
179
|
+
return `没有待问的问题了。\n${completionReport(state, specRel, [])}`;
|
|
180
|
+
}
|
|
181
|
+
return [
|
|
182
|
+
'没有可问的新问题了,但必答项还没齐——只剩被跳过的必答项,需要直接回答(不能靠 skip 过关)。',
|
|
183
|
+
`缺口:${blockersNow.map(b => `${b.id}(${b.ask})`).join(';')}`,
|
|
184
|
+
'下一步:thesis_intake action=answer question_id=<id> answer=<答案>',
|
|
185
|
+
].join('\n');
|
|
186
|
+
}
|
|
187
|
+
const asked = Math.min(sessionAsked + 1, maxQuestions);
|
|
188
|
+
return questionCard(q, state, ctx, [`第 ${asked}/${maxQuestions} 个问题(本次会话上限)`]);
|
|
189
|
+
}
|
|
190
|
+
case 'answer': {
|
|
191
|
+
const id = typeof args.question_id === 'string' ? args.question_id.trim() : '';
|
|
192
|
+
const raw = typeof args.answer === 'string' ? args.answer : '';
|
|
193
|
+
if (id === '')
|
|
194
|
+
return 'action=answer 需要 question_id(问题 id,如 school.template)。先 action=ask 取当前该答的问题。';
|
|
195
|
+
if (raw.trim() === '')
|
|
196
|
+
return `action=answer 需要 answer(问题 ${id} 的答案原文不能为空)。确实答不了的用 action=skip 并给理由。`;
|
|
197
|
+
const q = questionById(id);
|
|
198
|
+
if (q === undefined) {
|
|
199
|
+
const current = await loadOrInit(io, root, intake);
|
|
200
|
+
const ctx = await loadContext(io, root);
|
|
201
|
+
const queue = questionQueue(current.state, ctx).slice(0, 5).map(x => x.id);
|
|
202
|
+
return `未知问题 id:${id}。\n当前可答的问题:${queue.join('、') || '(无,先 action=ask)'}`;
|
|
203
|
+
}
|
|
204
|
+
const { state } = await loadOrInit(io, root, intake);
|
|
205
|
+
const ctx = await loadContext(io, root);
|
|
206
|
+
const insight = materialInsight(ctx.materials);
|
|
207
|
+
const auto = insight.autoAnswers.get(q.id);
|
|
208
|
+
// 材料已给出答案:用户回「对/是/确认」即确认,回别的内容按纠正处理。
|
|
209
|
+
const looksConfirm = auto !== undefined && /^(对|是|是的|确认|没错|正确|ok|好|可以|yes|y)$/i.test(raw.trim());
|
|
210
|
+
const value = looksConfirm ? auto.value : raw;
|
|
211
|
+
const check = checkAnswer(q, value);
|
|
212
|
+
const result = applyAnswer(state, q.id, value, {
|
|
213
|
+
source: looksConfirm ? 'materials' : 'user',
|
|
214
|
+
confirmed: true,
|
|
215
|
+
valid: check.ok,
|
|
216
|
+
note: check.note,
|
|
217
|
+
});
|
|
218
|
+
const written = await writeSpec(io, root, state, ctx);
|
|
219
|
+
await saveState(io, root, state);
|
|
220
|
+
const echo = [
|
|
221
|
+
looksConfirm
|
|
222
|
+
? `已确认(来源:材料推断):${q.id} = ${value}`
|
|
223
|
+
: `已记录:${q.id} = ${result.record.value}${result.changed ? '' : '(与上次相同,未重复记录)'}`,
|
|
224
|
+
];
|
|
225
|
+
// 已回答 + 即将问的都要记进 askedIds:前者是断点续问的进度,后者是 maxQuestions 的计分。
|
|
226
|
+
markAsked(state, q.id, new Date());
|
|
227
|
+
const next = nextQuestion(state, ctx);
|
|
228
|
+
if (next !== undefined)
|
|
229
|
+
markAsked(state, next.id, new Date());
|
|
230
|
+
await saveState(io, root, state);
|
|
231
|
+
if (check.ok) {
|
|
232
|
+
if (check.note !== undefined)
|
|
233
|
+
echo.push(`提醒:${check.note}`);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
echo.push(`⚠️ 格式提醒:${check.note ?? '未通过校验'}(已按原样记录;必答项格式不合格时 done 会拒绝)`);
|
|
237
|
+
}
|
|
238
|
+
echo.push(`规格已刷新:${written.path}`);
|
|
239
|
+
if (next === undefined) {
|
|
240
|
+
const blockersNow = blockingRequired(state);
|
|
241
|
+
if (blockersNow.length === 0) {
|
|
242
|
+
echo.push('');
|
|
243
|
+
echo.push(completionReport(state, written.path, []));
|
|
244
|
+
return echo.join('\n');
|
|
245
|
+
}
|
|
246
|
+
echo.push('');
|
|
247
|
+
echo.push('没有可问的新问题了,但还有必答缺口(可能是被跳过或格式不合格的):');
|
|
248
|
+
echo.push(blockersNow.map(b => `- ${b.id}:${b.ask}`).join('\n'));
|
|
249
|
+
echo.push('下一步:thesis_intake action=done 查看缺口,或直接 answer 补答。');
|
|
250
|
+
return echo.join('\n');
|
|
251
|
+
}
|
|
252
|
+
return questionCard(next, state, ctx, echo);
|
|
253
|
+
}
|
|
254
|
+
case 'skip': {
|
|
255
|
+
const id = typeof args.question_id === 'string' ? args.question_id.trim() : '';
|
|
256
|
+
const reason = typeof args.reason === 'string' ? args.reason.trim() : '';
|
|
257
|
+
if (id === '')
|
|
258
|
+
return 'action=skip 需要 question_id。';
|
|
259
|
+
if (reason === '')
|
|
260
|
+
return `action=skip 必须带 reason(跳过 ${id} 的理由)。跳过会被写进规格的阻塞项,必答项跳过仍会挡住 done。`;
|
|
261
|
+
const q = questionById(id);
|
|
262
|
+
if (q === undefined)
|
|
263
|
+
return `未知问题 id:${id}。可用 action=status 查看全部问题与进度。`;
|
|
264
|
+
const { state } = await loadOrInit(io, root, intake);
|
|
265
|
+
const ctx = await loadContext(io, root);
|
|
266
|
+
applySkip(state, id, reason);
|
|
267
|
+
markAsked(state, id, new Date());
|
|
268
|
+
const written = await writeSpec(io, root, state, ctx);
|
|
269
|
+
await saveState(io, root, state);
|
|
270
|
+
const echo = [
|
|
271
|
+
`已跳过:${id}(理由:${reason})`,
|
|
272
|
+
q.required ? '⚠️ 这是必答项:跳过不会解除阻塞,done 仍会拒绝,直到你真正回答它。' : '非必答项,规格里会标注为未知。',
|
|
273
|
+
`规格已刷新:${written.path}`,
|
|
274
|
+
];
|
|
275
|
+
const next = nextQuestion(state, ctx);
|
|
276
|
+
if (next === undefined) {
|
|
277
|
+
const blockersNow = blockingRequired(state);
|
|
278
|
+
echo.push('');
|
|
279
|
+
echo.push(blockersNow.length === 0
|
|
280
|
+
? completionReport(state, written.path, [])
|
|
281
|
+
: `还有必答缺口:${blockersNow.map(b => b.id).join('、')}——这些不能用 skip 过关。`);
|
|
282
|
+
return echo.join('\n');
|
|
283
|
+
}
|
|
284
|
+
return questionCard(next, state, ctx, echo);
|
|
285
|
+
}
|
|
286
|
+
case 'status': {
|
|
287
|
+
const { state, created } = await loadOrInit(io, root, intake);
|
|
288
|
+
const ctx = await loadContext(io, root);
|
|
289
|
+
const required = listRequired();
|
|
290
|
+
const blockersNow = blockingRequired(state);
|
|
291
|
+
const queue = questionQueue(state, ctx);
|
|
292
|
+
const lines = [];
|
|
293
|
+
lines.push(created ? '(此前没有状态,已初始化一份新状态。)' : '追问现状');
|
|
294
|
+
lines.push(`- 工作区:${root}`);
|
|
295
|
+
lines.push(`- 阶段:${ctx.stage}${ctx.materials.found ? `;材料清单已读取(${ctx.materials.items.length} 项)` : ';未找到 00-管理/材料清单.md'}`);
|
|
296
|
+
lines.push(`- 状态:${state.stage === 'done' ? '✅ 必答项已齐全' : '⏳ 进行中'};最近更新 ${state.updatedAt}`);
|
|
297
|
+
lines.push(`- 进度:${progressLine(state)}`);
|
|
298
|
+
lines.push('');
|
|
299
|
+
lines.push('必答项:');
|
|
300
|
+
for (const q of required) {
|
|
301
|
+
const rec = state.answers[q.id];
|
|
302
|
+
const mark = rec === undefined
|
|
303
|
+
? (state.skipped.includes(q.id) ? '⏭️ 已跳过(仍阻塞)' : '⬜ 未答')
|
|
304
|
+
: (rec.valid === false ? `⚠️ 已答但格式待修正(${rec.note ?? ''})` : (rec.confirmed ? '✅ 已确认' : '🟡 材料推断待确认'));
|
|
305
|
+
lines.push(`- ${q.id}(${q.section}):${mark}`);
|
|
306
|
+
}
|
|
307
|
+
lines.push('');
|
|
308
|
+
lines.push(`非必答项:${QUESTIONS.length - required.length} 个,已答 ${Object.keys(state.answers).length - required.filter(q => state.answers[q.id] !== undefined).length} 个`);
|
|
309
|
+
lines.push(queue.length > 0 ? `待问队列(前 5):${queue.slice(0, 5).map(q => q.id).join('、')}` : '待问队列:空');
|
|
310
|
+
lines.push(`阻塞项(${blockersNow.length}):${blockersNow.map(q => q.id).join('、') || '无'}`);
|
|
311
|
+
lines.push('');
|
|
312
|
+
lines.push(`规格位置:${state.specPath === '' ? specRel : state.specPath}`);
|
|
313
|
+
lines.push('下一步:' + (queue.length > 0
|
|
314
|
+
? `thesis_intake action=ask 取下一个问题(当前该答:${queue[0].id})`
|
|
315
|
+
: (blockersNow.length > 0 ? 'thesis_intake action=answer question_id=<缺口 id> answer=<答案>' : 'thesis_intake action=done')));
|
|
316
|
+
return lines.join('\n');
|
|
317
|
+
}
|
|
318
|
+
case 'spec': {
|
|
319
|
+
const { state } = await loadOrInit(io, root, intake);
|
|
320
|
+
const ctx = await loadContext(io, root);
|
|
321
|
+
const written = await writeSpec(io, root, state, ctx);
|
|
322
|
+
await saveState(io, root, state);
|
|
323
|
+
const lines = [];
|
|
324
|
+
lines.push(`规格已重绘并落盘:${written.path}(${written.bytes} 字符)`);
|
|
325
|
+
lines.push(`必答项:${progressLine(state)}`);
|
|
326
|
+
lines.push(written.blockerIds.length > 0
|
|
327
|
+
? `阻塞项(${written.blockerIds.length}):${written.blockerIds.join('、')}——规格里已显式标注为「未知(需补)」。`
|
|
328
|
+
: '阻塞项:无。');
|
|
329
|
+
const next = nextQuestion(state, ctx);
|
|
330
|
+
if (next !== undefined) {
|
|
331
|
+
lines.push('');
|
|
332
|
+
lines.push(`继续追问:thesis_intake action=ask(下一个问题:${next.id})`);
|
|
333
|
+
}
|
|
334
|
+
else if (written.blockerIds.length === 0) {
|
|
335
|
+
lines.push('');
|
|
336
|
+
lines.push('没有待问的问题了:可以 thesis_intake action=done 收尾。');
|
|
337
|
+
}
|
|
338
|
+
return lines.join('\n');
|
|
339
|
+
}
|
|
340
|
+
case 'done': {
|
|
341
|
+
const { state } = await loadOrInit(io, root, intake);
|
|
342
|
+
const ctx = await loadContext(io, root);
|
|
343
|
+
const blockersNow = blockingRequired(state);
|
|
344
|
+
if (blockersNow.length > 0) {
|
|
345
|
+
const lines = [];
|
|
346
|
+
lines.push(`无法完成追问:还有 ${blockersNow.length} 个必答项没齐,规格不算收敛,不能进入下一阶段。`);
|
|
347
|
+
lines.push('');
|
|
348
|
+
for (const q of blockersNow) {
|
|
349
|
+
const rec = state.answers[q.id];
|
|
350
|
+
const why = rec === undefined
|
|
351
|
+
? (state.skipped.includes(q.id) ? `已跳过(${state.skipReasons[q.id] ?? '未给理由'})——跳过不能代替回答` : '尚未回答')
|
|
352
|
+
: `已答但格式不合格:${rec.note ?? '校验未通过'}`;
|
|
353
|
+
lines.push(`- ${q.id}(${q.section}):${why}`);
|
|
354
|
+
lines.push(` 问:${q.ask}`);
|
|
355
|
+
lines.push(` 合格答案:${q.answerShape}`);
|
|
356
|
+
}
|
|
357
|
+
lines.push('');
|
|
358
|
+
lines.push('下一步:逐个回答上面的问题(thesis_intake action=answer question_id=<id> answer=<答案>),再 action=done。');
|
|
359
|
+
return lines.join('\n');
|
|
360
|
+
}
|
|
361
|
+
const transition = setStage(state, 'done');
|
|
362
|
+
if (!transition.ok)
|
|
363
|
+
return `无法完成追问:${transition.reason}`;
|
|
364
|
+
const written = await writeSpec(io, root, state, ctx);
|
|
365
|
+
await saveState(io, root, state);
|
|
366
|
+
const sections = new Set(listRequired().map(q => q.section));
|
|
367
|
+
const lines = [];
|
|
368
|
+
lines.push('✅ 追问完成:必答项齐全,意图规格已收敛并落盘。');
|
|
369
|
+
lines.push(`- 规格:${written.path}(${written.bytes} 字符)`);
|
|
370
|
+
lines.push(`- 必答项:${listRequired().length} 项全部已答,覆盖分节:${[...sections].join('、')}`);
|
|
371
|
+
lines.push(`- 已答问题总数:${Object.keys(state.answers).length}/${QUESTIONS.length};跳过 ${state.skipped.length} 项${state.skipped.length > 0 ? `(${state.skipped.join('、')})` : ''}`);
|
|
372
|
+
lines.push(`- 材料感知:${ctx.materials.found ? `清单 ${ctx.materials.items.length} 项,其中 ${materialInsight(ctx.materials).autoAnswers.size} 项由材料直接回答(已请用户确认)` : '未找到材料清单,全部问题由用户回答'}`);
|
|
373
|
+
lines.push('');
|
|
374
|
+
lines.push('规格里的未知条目(来源列为「未知」或「材料推断(未确认)」)仍会保留,写作/检查前请优先补齐;如发现答案有误,重答对应问题即可自动刷新规格。');
|
|
375
|
+
return lines.join('\n');
|
|
376
|
+
}
|
|
377
|
+
default: {
|
|
378
|
+
return `未知 action:${action}。有效值:${INTAKE_ACTIONS.join(' | ')}。`;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
// 写盘/读盘/校验的内部错误必须**抛出去**:工具的返回值就是模型看到的全部信息,
|
|
384
|
+
// 把异常渲染成一段「执行失败」的普通文本会让模型以为这次调用成功了,
|
|
385
|
+
// 从而在状态并未落盘的情况下继续写作。宿主的错误渲染会带上可读原因。
|
|
386
|
+
if (error instanceof Error)
|
|
387
|
+
throw error;
|
|
388
|
+
throw new Error(`thesis_intake 执行失败(${action}):${String(error)}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/** 供其它模块/测试复用:一次裁剪后的待问队列(受 maxQuestions 限制)。 */
|
|
392
|
+
export function pendingQueue(state, ctx, maxQuestions) {
|
|
393
|
+
return clampQueue(questionQueue(state, ctx), maxQuestions);
|
|
394
|
+
}
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// 装配:工具 + 斜杠命令
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
/**
|
|
399
|
+
* 注册 `thesis_intake`(单个工具、7 个 action)。
|
|
400
|
+
*
|
|
401
|
+
* 工具定义走仓库统一的 `definePaperTool`(生产 = 宿主真实 `defineTool`,
|
|
402
|
+
* 离线 = `shared/define-tool.ts` 的等价实现),因此本模块在测试环境里可加载。
|
|
403
|
+
* 工作区根由 `findThesisRoot` 从会话 cwd 向上探测 `00-管理/进度台账.md`;
|
|
404
|
+
* 找不到时工具返回明确指引(先 `thesis_init`),不抛裸异常。
|
|
405
|
+
*/
|
|
406
|
+
export function registerIntake(ctx, options) {
|
|
407
|
+
const definition = intakeToolDefinition(ctx.fs, options.intake, runIntake);
|
|
408
|
+
ctx.tools.register(definePaperTool({
|
|
409
|
+
name: definition.name,
|
|
410
|
+
description: definition.description,
|
|
411
|
+
parameters: definition.parameters,
|
|
412
|
+
output: textOutput(),
|
|
413
|
+
async execute(rawArgs, exec) {
|
|
414
|
+
return await definition.execute(rawArgs, exec);
|
|
415
|
+
},
|
|
416
|
+
}));
|
|
417
|
+
registerIntakeCommand(ctx, options);
|
|
418
|
+
}
|
|
419
|
+
/** 把 `/thesis-intake` 的一行输入翻译成追问动作。 */
|
|
420
|
+
export function parseCommandInput(raw) {
|
|
421
|
+
const text = raw.trim();
|
|
422
|
+
if (text === '')
|
|
423
|
+
return { action: 'ask' };
|
|
424
|
+
const [head, ...rest] = text.split(/\s+/);
|
|
425
|
+
switch (head) {
|
|
426
|
+
case 'start':
|
|
427
|
+
case 'ask':
|
|
428
|
+
case 'status':
|
|
429
|
+
case 'spec':
|
|
430
|
+
case 'done':
|
|
431
|
+
return { action: head };
|
|
432
|
+
case 'answer': {
|
|
433
|
+
const [id, ...words] = rest;
|
|
434
|
+
return { action: 'answer', question_id: id ?? '', answer: words.join(' ') };
|
|
435
|
+
}
|
|
436
|
+
case 'skip': {
|
|
437
|
+
const [id, ...words] = rest;
|
|
438
|
+
return { action: 'skip', question_id: id ?? '', reason: words.join(' ') };
|
|
439
|
+
}
|
|
440
|
+
default:
|
|
441
|
+
// 不认识的第一个词:当作问题 id 的简写,等价于 answer(模型/人最常这么用)。
|
|
442
|
+
return { action: 'answer', question_id: head ?? '', answer: rest.join(' ') };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* 注册命令 `/thesis-intake`(命令名只允许 `[a-z][a-z0-9_-]*`)。
|
|
447
|
+
*
|
|
448
|
+
* 用法:`/thesis-intake` 取下一个问题;`/thesis-intake status|spec|done|start`;
|
|
449
|
+
* `/thesis-intake answer school.name XX大学`;`/thesis-intake skip ref.samples 暂时没有`。
|
|
450
|
+
* `ctx.commands` 不可用(未装配命令服务的宿主)时静默跳过,不影响工具能力。
|
|
451
|
+
*/
|
|
452
|
+
export function registerIntakeCommand(ctx, options) {
|
|
453
|
+
const commands = ctx.commands;
|
|
454
|
+
if (commands === undefined || typeof commands.register !== 'function')
|
|
455
|
+
return false;
|
|
456
|
+
commands.register({
|
|
457
|
+
name: 'thesis-intake',
|
|
458
|
+
description: '逐题追问:材料到手后把模糊要求变成意图规格(一次一个问题)',
|
|
459
|
+
input: { hint: 'answer <问题id> <答案>|skip <问题id> <理由>|status|spec|done(缺省取下一个问题)' },
|
|
460
|
+
async handler(invocation) {
|
|
461
|
+
const args = parseCommandInput(invocation.rawInput ?? '');
|
|
462
|
+
const text = await runIntake(ctx.fs, options.intake, args, sessionCwd(invocation), invocation.signal);
|
|
463
|
+
// 工具层已经把失败写成可读中文,这里统一按 success 返回(命令不做二次包装)。
|
|
464
|
+
return { kind: 'success', text };
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 材料到手后的逐题追问:问题库、排序裁剪、材料感知。
|
|
3
|
+
*
|
|
4
|
+
* 设计要点(与 `gates/detect.ts` 同一套「便宜、面向人、有终止条件」):
|
|
5
|
+
* 1. **一次只问一个**:本模块只回答「下一个该问什么」,绝不在一次返回里塞多个问题;
|
|
6
|
+
* 2. **可证伪**:每个问题都写清 `why`(不答会返什么工)、`affects`(影响哪个产出物)、
|
|
7
|
+
* `answerShape`(什么样的答案算合格),使回答能被机器写进意图规格并被后续阶段校验;
|
|
8
|
+
* 3. **材料感知**:`thesis_ingest` 已经把材料读进工作区,凡是材料里已经有的答案就
|
|
9
|
+
* **不再重复问**——能自动回答的标为「待用户确认」,能安全推断的直接免问。
|
|
10
|
+
* 这是「一次到位」的关键:问用户之前先问工作区。
|
|
11
|
+
*
|
|
12
|
+
* 本文件是纯函数模块:零 I/O、零宿主依赖,输入 `IntakeState` + {@link IntakeContext},
|
|
13
|
+
* 输出结构化的下一个问题。所有排序都是全序(priority → 问题库顺序),结果确定。
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-thesis/intake
|
|
16
|
+
*/
|
|
17
|
+
import type { IntakeState } from './state.ts';
|
|
18
|
+
/**
|
|
19
|
+
* 追问影响的产出物(`affects` 的取值范围)。
|
|
20
|
+
*
|
|
21
|
+
* **可达性契约**:每个取值都必须被至少一个真实工具兑现(见 {@link AFFECT_TOOLS})。
|
|
22
|
+
* 这条约束由 `tests/intake-questions.test.ts` 断言:新增问题若声称影响一个
|
|
23
|
+
* 没有工具兑现的产出物,测试立刻变红——这样「问了一堆用不上的要求」无法退化。
|
|
24
|
+
*/
|
|
25
|
+
export type IntakeAffect = '格式检查' | '引用规范' | '字数分配' | '写作规范' | '原创性' | '答案PPT' | '答辩问答' | '文献检索';
|
|
26
|
+
/** 每个产出物由哪些工具兑现(工具名是契约,见 DESIGN.md §5)。 */
|
|
27
|
+
export declare const AFFECT_TOOLS: Readonly<Record<IntakeAffect, readonly string[]>>;
|
|
28
|
+
/** 轻校验结果:`ok:false` + `note` 表示「记录但标注问题」,不阻断对话。 */
|
|
29
|
+
export interface AnswerCheck {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
note?: string;
|
|
32
|
+
}
|
|
33
|
+
/** 轻校验函数:写成显式签名,避免对象字面量里的参数被推断为 any。 */
|
|
34
|
+
export type AnswerValidator = (answer: string) => AnswerCheck;
|
|
35
|
+
/** 结构化问题:写给用户的一句话 + 为什么问 + 合格答案形态。 */
|
|
36
|
+
export interface IntakeQuestion {
|
|
37
|
+
/** 稳定 id,如 'school.name'(写进状态与规格,改动即破坏断点续问)。 */
|
|
38
|
+
id: string;
|
|
39
|
+
/** 规格里的分节,如 '学校与规范'。 */
|
|
40
|
+
section: string;
|
|
41
|
+
/** 问给用户的一句话(口语、便宜、一次一问)。 */
|
|
42
|
+
ask: string;
|
|
43
|
+
/** 为什么问:不答会导致什么返工(必须是具体的返工,不是"信息不完整")。 */
|
|
44
|
+
why: string;
|
|
45
|
+
/** 影响的产出物。 */
|
|
46
|
+
affects: IntakeAffect[];
|
|
47
|
+
/** 合格答案的形态(含示例),用户照着答即可。 */
|
|
48
|
+
answerShape: string;
|
|
49
|
+
/** 不答就无法进入下一阶段(`done` 会拒绝)。 */
|
|
50
|
+
required: boolean;
|
|
51
|
+
/** 用户答不上来时的提示:去哪找(教务处/学长/导师/学校文件)。 */
|
|
52
|
+
hint?: string;
|
|
53
|
+
/** 轻校验:格式不对只给 note,绝不替用户改写答案。 */
|
|
54
|
+
validate?: AnswerValidator;
|
|
55
|
+
/** 排序权重,1 最高(同优先级下小者先问)。 */
|
|
56
|
+
priority: number;
|
|
57
|
+
}
|
|
58
|
+
/** 材料清单里的一个条目(`00-管理/材料清单.md` 的一行)。 */
|
|
59
|
+
export interface MaterialEntry {
|
|
60
|
+
/** 材料在论文工作区里的相对路径(或文件名)。 */
|
|
61
|
+
readonly path: string;
|
|
62
|
+
/** 清单声明的类型,如 `学校模板`、`实验数据`;缺省为空串。 */
|
|
63
|
+
readonly kind: string;
|
|
64
|
+
/** 低置信度的粗分类,用于兜底匹配(docx/xlsx/csv/md/pdf…)。 */
|
|
65
|
+
readonly ext: string;
|
|
66
|
+
}
|
|
67
|
+
/** 工作区材料摘要:`thesis_ingest` 的产物经宽容解析后的结果。 */
|
|
68
|
+
export interface MaterialSummary {
|
|
69
|
+
/** 清单文件是否存在(缺失时 items 为空,一切照常提问)。 */
|
|
70
|
+
readonly found: boolean;
|
|
71
|
+
readonly items: readonly MaterialEntry[];
|
|
72
|
+
}
|
|
73
|
+
/** 追问上下文:材料摘要 + 当前阶段(由台账推断)。 */
|
|
74
|
+
export interface IntakeContext {
|
|
75
|
+
readonly materials: MaterialSummary;
|
|
76
|
+
/** 台账里的阶段名,如 `开题`;读不到台账时用 {@link DEFAULT_STAGE}。 */
|
|
77
|
+
readonly stage: string;
|
|
78
|
+
}
|
|
79
|
+
/** 读不到台账时的默认阶段(任务约定:按「开题前」处理)。 */
|
|
80
|
+
export declare const DEFAULT_STAGE = "\u5F00\u9898\u524D";
|
|
81
|
+
/** 空材料摘要:没有清单时的默认值。 */
|
|
82
|
+
export declare const EMPTY_MATERIALS: MaterialSummary;
|
|
83
|
+
/** 全部规格分节(渲染顺序即此顺序,保证幂等)。 */
|
|
84
|
+
export declare const INTAKE_SECTIONS: readonly string[];
|
|
85
|
+
/** 阶段 → 优先命中的产出物;用于把「当前阶段最要紧的问题」排前面。 */
|
|
86
|
+
export declare const STAGE_AFFECTS: Readonly<Record<string, readonly IntakeAffect[]>>;
|
|
87
|
+
/** 从答案里取第一个整数("3 万字" → 3;"12000" → 12000)。 */
|
|
88
|
+
export declare function firstNumber(answer: string): number | undefined;
|
|
89
|
+
/**
|
|
90
|
+
* 日期合理性检查。只拦「明显不可能」的日期(如 2026-13-45),
|
|
91
|
+
* 不判断日期是否已过——过期提醒是台账的职责,不是追问的职责。
|
|
92
|
+
*/
|
|
93
|
+
export declare function validateDeadline(answer: string, now?: Date): AnswerCheck;
|
|
94
|
+
/**
|
|
95
|
+
* 追问问题库。分节顺序即 {@link INTAKE_SECTIONS} 的顺序。
|
|
96
|
+
*
|
|
97
|
+
* `required: true` 的问题不答就无法 `done`;其余问题可以 `skip`(须给理由)。
|
|
98
|
+
*/
|
|
99
|
+
export declare const QUESTIONS: readonly IntakeQuestion[];
|
|
100
|
+
export declare function questionById(id: string): IntakeQuestion | undefined;
|
|
101
|
+
export declare function listRequired(): IntakeQuestion[];
|
|
102
|
+
/** 全部问题 id(现状报告用)。 */
|
|
103
|
+
export declare function allQuestionIds(): string[];
|
|
104
|
+
/** 某问题是否已被回答(跳过不算回答)。 */
|
|
105
|
+
export declare function isAnswered(state: IntakeState, q: IntakeQuestion): boolean;
|
|
106
|
+
/** 某回答是否还需要用户确认(材料推断出来的答案)。 */
|
|
107
|
+
export declare function needsConfirm(state: IntakeState, q: IntakeQuestion): boolean;
|
|
108
|
+
/** 找第一份学校格式模板:文件名/类型里出现"模板/格式/规范",或 kind 明确为学校模板。 */
|
|
109
|
+
export declare function findTemplateMaterial(materials: MaterialSummary): MaterialEntry | undefined;
|
|
110
|
+
/** 材料推断出的一条答案:值 + 依据 + 是否还需要用户确认。 */
|
|
111
|
+
export interface MaterialAnswer {
|
|
112
|
+
readonly value: string;
|
|
113
|
+
readonly evidence: string;
|
|
114
|
+
/** true = 必须有用户确认才算数(规格里标「待确认」)。 */
|
|
115
|
+
readonly needsConfirm: boolean;
|
|
116
|
+
}
|
|
117
|
+
/** 材料感知结果。 */
|
|
118
|
+
export interface MaterialInsight {
|
|
119
|
+
/**
|
|
120
|
+
* 材料已给出答案、但仍需用户确认的问题。
|
|
121
|
+
* 这类问题会以「已由材料回答,待用户确认」的形态问一次,用户确认或纠正后落盘。
|
|
122
|
+
*/
|
|
123
|
+
readonly autoAnswers: ReadonlyMap<string, MaterialAnswer>;
|
|
124
|
+
/**
|
|
125
|
+
* 材料已能安全推断、不必再打扰用户的问题。
|
|
126
|
+
* 这些 id 不会出现在追问队列里(规格里标注来源为「材料推断」)。
|
|
127
|
+
*/
|
|
128
|
+
readonly suppress: ReadonlySet<string>;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* 从材料清单摘要推断哪些问题可以免问 / 自动回答。
|
|
132
|
+
*
|
|
133
|
+
* 纯函数:同样的清单与状态永远给出同样结果。清单缺失(`found:false`)时
|
|
134
|
+
* 什么都不推断,一切照常提问——**宽容优先**,不猜。
|
|
135
|
+
*/
|
|
136
|
+
export declare function materialInsight(materials: MaterialSummary): MaterialInsight;
|
|
137
|
+
/**
|
|
138
|
+
* 材料自动回答里的「确认清单」:`autoAnswers` 中尚未被用户确认的部分。
|
|
139
|
+
* 这些是让「一次到位」落地的关键——不重复问,但也不替用户拍板。
|
|
140
|
+
*/
|
|
141
|
+
export declare function pendingConfirmations(state: IntakeState, insight: MaterialInsight): IntakeQuestion[];
|
|
142
|
+
/**
|
|
143
|
+
* 当前应该问的下一个问题;没有可问的返回 undefined。
|
|
144
|
+
*
|
|
145
|
+
* 规则(按优先级):
|
|
146
|
+
* 1. 材料已给出答案、只差用户确认的;
|
|
147
|
+
* 2. `required` 且未答(或格式不合格)的;
|
|
148
|
+
* 3. 其余未答的,按「当前阶段命中的产出物 → priority → 问题库顺序」;
|
|
149
|
+
* 4. 已跳过的不再问(要改就显式重答)。
|
|
150
|
+
*/
|
|
151
|
+
export declare function nextQuestion(state: IntakeState, ctx: IntakeContext): IntakeQuestion | undefined;
|
|
152
|
+
/**
|
|
153
|
+
* 问题队列快照(不含已确认的答案):用于报告「还差什么」。
|
|
154
|
+
* 返回顺序与 {@link nextQuestion} 的选取顺序一致,但不做 maxQuestions 裁剪。
|
|
155
|
+
*/
|
|
156
|
+
export declare function questionQueue(state: IntakeState, ctx: IntakeContext): IntakeQuestion[];
|
|
157
|
+
/** 阻塞项:`required` 但未答、或已答但格式不合格(`valid === false`)。 */
|
|
158
|
+
export declare function blockingRequired(state: IntakeState): IntakeQuestion[];
|
|
159
|
+
/** 进度:已答(含未确认)/ 必答总数。 */
|
|
160
|
+
export declare function progress(state: IntakeState): {
|
|
161
|
+
answered: number;
|
|
162
|
+
total: number;
|
|
163
|
+
required: number;
|
|
164
|
+
confirmed: number;
|
|
165
|
+
};
|
|
166
|
+
/** 一次问答循环可推进的问题数(`maxQuestions` 上限的裁剪视图)。 */
|
|
167
|
+
export declare function clampQueue(queue: readonly IntakeQuestion[], maxQuestions: number): IntakeQuestion[];
|
|
168
|
+
/** 组装一个规格上下文:材料感知 + 阶段。 */
|
|
169
|
+
export declare function makeContext(materials: MaterialSummary, stage: string | undefined): IntakeContext;
|