dsh-convo-paper-plugin 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LiShaozheli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,67 @@
1
- # Temporary Holding Version
1
+ # dsh-convo-paper-plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DeepSeek Harness 插件版:把对话整理成论文,并用确定性检索核验每一条引用。
4
+
5
+ 这是 [dsh-convo-paper](../README.md) 的宿主插件形态。核心逻辑与技能版完全一致(`dsh/lib/` 由 `lib/` 生成,`npm test` 会校验两份不漂移),区别在于它把自己注册进 DSH 的工具与命令系统:
6
+
7
+ | 能力 | 说明 |
8
+ | --- | --- |
9
+ | `paper_extract` | 读会话日志,按说话人还原上下文,剔除系统注入 → `transcript.md` / `conversation.json` |
10
+ | `paper_claims` | 提炼带出处的候选论点(id / 说话人 / 轮次 / 时间戳 / 建议章节)→ `claims.json` / `claims.md` |
11
+ | `paper_build` | 组装论文骨架与证据对照表 → `paper-skeleton.md` / `evidence.md` |
12
+ | `paper_verify_refs` | 四源核验引用(Crossref / DataCite / OpenAlex / arXiv),DOI 反查 + 标题相似度 |
13
+ | `paper_run` | 一步跑完 extract → claims → build |
14
+ | `/paper` | 斜杠命令,等价于 `paper_run` |
15
+
16
+ ## 安装
17
+
18
+ 插件需要装进 profile(不是拷文件夹):
19
+
20
+ ```bash
21
+ dsh plugin --profile desktop add github:LiShaozheli/dsh-convo-paper#path:/plugin
22
+ ```
23
+
24
+ 装完重启 DSH,`设置 → 插件` 里会出现 **convo-paper**,输入框输入 `/paper` 可用。
25
+
26
+ 也可以只在某个 profile 里试用:
27
+
28
+ ```bash
29
+ dsh plugin --profile web add github:LiShaozheli/dsh-convo-paper#path:/plugin
30
+ ```
31
+
32
+ ## 与技能版的取舍
33
+
34
+ | | 插件版 | 技能版 |
35
+ | --- | --- | --- |
36
+ | 出现在「已安装插件」 | ✅ | ❌(在技能列表/`@` 菜单里) |
37
+ | 斜杠命令 `/paper` | ✅ | ❌ |
38
+ | 原生工具,模型可直接调用 | ✅ | 读 `SKILL.md` 后手动跑 CLI |
39
+ | 安装成本 | 需要 profile 与重启 | 拷一个目录即可 |
40
+ | 依赖 | 无(不 import 任何 `@deepseek-ai/*` 值) | 无 |
41
+
42
+ ## 契约
43
+
44
+ ```js
45
+ export const name = 'convo-paper'
46
+ export const inject = ['tools']
47
+ export function apply(ctx) { ctx.effect(() => ctx.tools.register({...}), 'label') }
48
+ ```
49
+
50
+ - 工具用 `ctx.tools.register` 的普通对象定义(参数走 JSON Schema,输出走 `output.schema` + `render`),因此不依赖 `@deepseek-ai/dsh-tools` 的 `defineTool`,装到任何兼容 profile 都能跑。
51
+ - 命令用 `ctx.commands.register`,且做了存在性判断——宿主没有 commands 服务时自动跳过,不影响工具。
52
+ - 所有注册都包在 `ctx.effect(...)` 里,插件卸载时自动注销。
53
+
54
+ ## 验证
55
+
56
+ ```bash
57
+ node test/plugin.test.mjs # mock ctx:8 个用例,含真实会话日志端到端
58
+ node scripts/sync-core.mjs --check
59
+ ```
60
+
61
+ 测试会在临时 `DSH_HOME` 里造一份多帧 zstd 会话日志,套上 mock ctx 走完 `paper_run`,断言产物与控制台摘要。
62
+
63
+ ## 已知边界
64
+
65
+ - 保证引用的**存在性**,不保证**恰当性**。
66
+ - 中文数据库(知网/万方)无开放 API,中文文献需人工确认。
67
+ - 论点抽取是启发式的;操作密集型对话会提示素材不足,而不是硬写。
@@ -0,0 +1,6 @@
1
+ # dsh-convo-paper-plugin bundle patch: one host row over the Web or Desktop
2
+ # profile. The row name resolves from the profile's own node_modules, where
3
+ # `dsh plugin --profile <profile> add <package>` installed this package.
4
+ - insert:
5
+ - id: convo-paper
6
+ name: dsh-convo-paper-plugin
package/dsh/index.js ADDED
@@ -0,0 +1,202 @@
1
+ /**
2
+ * dsh-convo-paper-plugin — host half.
3
+ *
4
+ * Registers five tools and a `/paper` command on the DeepSeek Harness. Plain
5
+ * object definitions go through `ctx.tools.register` so the package depends on
6
+ * no `@deepseek-ai/*` value import and installs into any compatible profile.
7
+ */
8
+ import { runExtract, runClaims, runBuild, runVerifyRefs, runAll } from './pipeline.mjs';
9
+
10
+ export const name = 'convo-paper';
11
+ export const inject = ['tools'];
12
+
13
+ const LABEL = 'convo-paper';
14
+
15
+ const SESSION_PARAM = {
16
+ type: 'string',
17
+ description: '会话 id(`latest` 表示最近一次会话,也可传 id 片段)。留空等于 latest。',
18
+ };
19
+
20
+ const CWD_PARAM = {
21
+ type: 'string',
22
+ description: '会话所属工作目录;用于在多个项目之间消歧。',
23
+ };
24
+
25
+ const OUT_PARAM = {
26
+ type: 'string',
27
+ description: '产物目录,默认写到会话的 cwd 下的 paper-work/。',
28
+ };
29
+
30
+ function text(value) {
31
+ return [{ type: 'text', text: String(value) }];
32
+ }
33
+
34
+ function summarize(result) {
35
+ if (!result || result.ok === false) {
36
+ return `失败:${result?.message || result?.code || 'unknown error'}`;
37
+ }
38
+ if (result.step === 'extract') {
39
+ const s = result.stats || {};
40
+ return `已抽取 ${result.sessionId}:${s.turns ?? 0} 轮 / 用户 ${s.userMessages ?? 0} 条 / AI ${s.assistantMessages ?? 0} 条,剔除系统注入 ${s.injectedMessages ?? 0} 条 → ${result.out}`;
41
+ }
42
+ if (result.step === 'claims') {
43
+ return `候选论点 ${result.candidateCount} 条(${Object.entries(result.bySection || {})
44
+ .map(([k, v]) => `${k}:${v}`)
45
+ .join(',')})→ ${result.out}`;
46
+ }
47
+ if (result.step === 'build') {
48
+ return `骨架已生成:保留论点 ${result.keptClaims} 条,已核实文献 ${result.verifiedRefs} 条 → ${result.out}`;
49
+ }
50
+ if (result.step === 'verify-refs') {
51
+ return `核验 ${result.verdicts.length} 条:verified ${result.verified}|possible ${result.possible}|rejected ${result.rejected} → ${result.out}`;
52
+ }
53
+ if (result.step === 'run') {
54
+ return `${summarize(result.extracted)}\n${summarize(result.claims)}\n${summarize(result.built)}`;
55
+ }
56
+ return JSON.stringify(result);
57
+ }
58
+
59
+ function define(ctx, def) {
60
+ return ctx.effect(() => ctx.tools.register(def), `${LABEL} ${def.name}`);
61
+ }
62
+
63
+ export function apply(ctx) {
64
+ define(ctx, {
65
+ name: 'paper_extract',
66
+ description:
67
+ '读取一段 DSH 会话日志,按说话人还原上下文(用户说了什么 / AI 答了什么),剔除运行时上下文等系统注入,输出 transcript.md 与 conversation.json。做"把对话整理成论文"的第一步。',
68
+ parameters: {
69
+ type: 'object',
70
+ properties: { session: SESSION_PARAM, cwd: CWD_PARAM, out: OUT_PARAM, includeTools: { type: 'boolean', description: '是否在 transcript 中附上工具调用摘要' } },
71
+ additionalProperties: false,
72
+ },
73
+ output: { schema: { type: 'object' }, render: (_a, v) => text(summarize(v)) },
74
+ async execute(args = {}) {
75
+ return runExtract(args);
76
+ },
77
+ presentCall: (args = {}) => ({
78
+ card: 'generic',
79
+ title: '抽取对话上下文',
80
+ kind: 'read',
81
+ rawInput: String(args.session || 'latest'),
82
+ }),
83
+ });
84
+
85
+ define(ctx, {
86
+ name: 'paper_claims',
87
+ description:
88
+ '从已抽取的对话中提炼带出处的候选论点(观点/结论/创意),每条含 id、说话人、轮次、时间戳、建议章节,输出 claims.json 与 claims.md,供人工判定 keep/merge/drop。',
89
+ parameters: {
90
+ type: 'object',
91
+ properties: {
92
+ out: OUT_PARAM,
93
+ session: SESSION_PARAM,
94
+ cwd: CWD_PARAM,
95
+ perTurn: { type: 'number', description: '每轮最多保留多少条候选句,默认 6' },
96
+ },
97
+ additionalProperties: false,
98
+ },
99
+ output: { schema: { type: 'object' }, render: (_a, v) => text(summarize(v)) },
100
+ async execute(args = {}) {
101
+ return runClaims(args);
102
+ },
103
+ presentCall: () => ({ card: 'generic', title: '提炼候选论点', kind: 'read' }),
104
+ });
105
+
106
+ define(ctx, {
107
+ name: 'paper_build',
108
+ description:
109
+ '把保留的论点与已核实文献组装成论文骨架:摘要、六节正文、参考文献,并生成 evidence.md 证据对照表(论点→出处→支撑文献)。',
110
+ parameters: {
111
+ type: 'object',
112
+ properties: { out: OUT_PARAM, cwd: CWD_PARAM, title: { type: 'string', description: '论文标题;缺省用会话标题' } },
113
+ additionalProperties: false,
114
+ },
115
+ output: { schema: { type: 'object' }, render: (_a, v) => text(summarize(v)) },
116
+ async execute(args = {}) {
117
+ return runBuild(args);
118
+ },
119
+ presentCall: () => ({ card: 'generic', title: '生成论文骨架', kind: 'edit' }),
120
+ });
121
+
122
+ define(ctx, {
123
+ name: 'paper_verify_refs',
124
+ description:
125
+ '逐条核验参考文献:Crossref / DataCite / OpenAlex / arXiv 四源检索,DOI 反查 + 标题相似度判定。只有 verified 的条目允许进入正文,possible 需人工核对,rejected 必须丢弃。输入 [{id, claimId, title, doi}]。',
126
+ parameters: {
127
+ type: 'object',
128
+ properties: {
129
+ refs: {
130
+ type: 'array',
131
+ description: '待核验条目,title 必填,doi 强烈建议',
132
+ items: {
133
+ type: 'object',
134
+ properties: {
135
+ id: { type: 'string' },
136
+ claimId: { type: 'string', description: '这条文献支撑哪个论点(C001…)' },
137
+ title: { type: 'string' },
138
+ doi: { type: 'string' },
139
+ year: { type: 'number' },
140
+ },
141
+ required: ['title'],
142
+ additionalProperties: false,
143
+ },
144
+ },
145
+ out: OUT_PARAM,
146
+ cwd: CWD_PARAM,
147
+ },
148
+ required: ['refs'],
149
+ additionalProperties: false,
150
+ },
151
+ output: { schema: { type: 'object' }, render: (_a, v) => text(summarize(v)) },
152
+ async execute(args = {}) {
153
+ return runVerifyRefs(args);
154
+ },
155
+ presentCall: (args = {}) => ({
156
+ card: 'generic',
157
+ title: '核验参考文献',
158
+ kind: 'read',
159
+ rawInput: `${Array.isArray(args.refs) ? args.refs.length : 0} 条`,
160
+ }),
161
+ });
162
+
163
+ define(ctx, {
164
+ name: 'paper_run',
165
+ description: '一步完成 paper_extract → paper_claims → paper_build,产出可编辑的论文骨架。需要核验文献时再单独调用 paper_verify_refs。',
166
+ parameters: {
167
+ type: 'object',
168
+ properties: { session: SESSION_PARAM, cwd: CWD_PARAM, out: OUT_PARAM, includeTools: { type: 'boolean' }, perTurn: { type: 'number' } },
169
+ additionalProperties: false,
170
+ },
171
+ output: { schema: { type: 'object' }, render: (_a, v) => text(summarize(v)) },
172
+ async execute(args = {}) {
173
+ return runAll(args);
174
+ },
175
+ presentCall: (args = {}) => ({ card: 'generic', title: '对话转论文', kind: 'edit', rawInput: String(args.session || 'latest') }),
176
+ });
177
+
178
+ if (ctx.commands && typeof ctx.commands.register === 'function') {
179
+ ctx.effect(
180
+ () =>
181
+ ctx.commands.register({
182
+ name: 'paper',
183
+ description: '把当前(或指定)会话整理成论文骨架',
184
+ input: { hint: '[latest|<会话id>] [--out <目录>]' },
185
+ handler: async (invocation) => {
186
+ try {
187
+ const raw = String(invocation?.args ?? invocation?.input ?? invocation?.text ?? '').trim();
188
+ const parts = raw.split(/\s+/).filter(Boolean);
189
+ const session = parts[0] && parts[0] !== '--out' ? parts[0] : 'latest';
190
+ const outIdx = parts.indexOf('--out');
191
+ const out = outIdx >= 0 ? parts[outIdx + 1] : undefined;
192
+ const result = await runAll({ session, out });
193
+ return { kind: 'text', text: summarize(result) };
194
+ } catch (error) {
195
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) };
196
+ }
197
+ },
198
+ }),
199
+ `${LABEL} /paper command`,
200
+ );
201
+ }
202
+ }
@@ -0,0 +1,137 @@
1
+ // Vendored copy of lib/build.mjs — edit lib/ and run `node scripts/sync-core.mjs`.
2
+ /**
3
+ * Assemble the paper scaffold: sections, claim slots, citation slots, and the
4
+ * evidence table that traces every claim back to a turn and a speaker.
5
+ *
6
+ * It intentionally stops at the skeleton. Prose is written by the model under
7
+ * SKILL.md, because a template-filled document is not a paper.
8
+ */
9
+
10
+ export const SECTIONS = [
11
+ { key: '1', title: '引言', hint: '问题从哪里来:用户提出的是什么困扰,为什么值得写成文章' },
12
+ { key: '2', title: '相关工作', hint: '外部文献对照;每条引用必须来自 refs.verified' },
13
+ { key: '3', title: '核心观点与框架', hint: '对话中真正有创意的判断、类比、框架' },
14
+ { key: '4', title: '论证与证据', hint: '支撑上述观点的推理链、案例、反例、数据' },
15
+ { key: '5', title: '讨论与局限', hint: '边界条件、反方意见、尚未验证的假设' },
16
+ { key: '6', title: '结论与展望', hint: '收敛成可检验的结论与下一步工作' },
17
+ ];
18
+
19
+ function pickSection(c) {
20
+ const m = String(c.section || '').match(/^\s*(\d)/);
21
+ if (m && SECTIONS.some((s) => s.key === m[1])) return m[1];
22
+ return c.role === 'user' ? '1' : '3';
23
+ }
24
+
25
+ export function buildSkeleton({ conversation, claims, refs = [], title }) {
26
+ const kept = claims.filter((c) => c.status !== 'drop');
27
+ const groups = new Map(SECTIONS.map((s) => [s.key, []]));
28
+ for (const c of kept) groups.get(pickSection(c)).push(c);
29
+
30
+ const verified = refs.filter((r) => r.verdict === 'verified');
31
+ const possible = refs.filter((r) => r.verdict === 'possible');
32
+ const citeIndex = new Map();
33
+ verified.forEach((r, i) => citeIndex.set(r.id || r.record.doi || r.record.title, i + 1));
34
+
35
+ const docTitle = title || conversation.meta.title || '未命名论文';
36
+ const out = [];
37
+ out.push(`# ${docTitle}`);
38
+ out.push('');
39
+ out.push('> 骨架由 convo-paper 生成:括号内是写作指令,`[Cn]` 是可引用的观点锚点,`[n]` 是对应参考文献。');
40
+ out.push('');
41
+ out.push('## 摘要');
42
+ out.push('');
43
+ out.push('(用 200–300 字写:问题、方法、核心观点、结论。不要出现“本文借助 AI 对话”这类元叙述。)');
44
+ out.push('');
45
+ out.push('**关键词**:(3–5 个)');
46
+ out.push('');
47
+
48
+ for (const s of SECTIONS) {
49
+ out.push(`## ${s.key} ${s.title}`);
50
+ out.push('');
51
+ out.push(`<!-- 写作提示:${s.hint} -->`);
52
+ out.push('');
53
+ const list = groups.get(s.key) || [];
54
+ if (!list.length) {
55
+ out.push('(本节暂无对话素材:需要补充外部文献或由作者补写。)');
56
+ out.push('');
57
+ continue;
58
+ }
59
+ list.forEach((c, i) => {
60
+ const cite = c.citeId && citeIndex.has(c.citeId) ? ` [${citeIndex.get(c.citeId)}]` : '';
61
+ out.push(`${i + 1}. **[${c.id}]**(第${c.turn}轮·${c.role === 'user' ? '用户' : 'AI'})${c.text}${cite}`);
62
+ });
63
+ out.push('');
64
+ }
65
+
66
+ out.push('## 参考文献');
67
+ out.push('');
68
+ if (!verified.length) {
69
+ out.push('(尚无已核实文献。先跑 `convo-paper refs verify`,只有 verdict=verified 的记录才能写进来。)');
70
+ } else {
71
+ verified.forEach((r, i) => out.push(r.gbt7714.replace(/^\[\d+\]\s*/, `[${i + 1}] `)));
72
+ }
73
+ if (possible.length) {
74
+ out.push('');
75
+ out.push('<!-- 待人工核对(匹配度不足,暂不列入正式参考文献):');
76
+ for (const r of possible) out.push(` - ${r.record.title}(相似度 ${r.similarity})${r.record.doi || ''}`);
77
+ out.push('-->');
78
+ }
79
+ out.push('');
80
+ return out.join('\n');
81
+ }
82
+
83
+ export function buildEvidenceTable({ conversation, claims, refs = [] }) {
84
+ const out = [];
85
+ out.push('# 证据对照表');
86
+ out.push('');
87
+ out.push('每一条进入正文的判断,都必须能在这张表里找到出处。审稿人问“这个结论哪来的”,答案在这里。');
88
+ out.push('');
89
+ out.push('| 观点 | 说话人 | 轮次 | 原文摘要 | 支撑文献 | 状态 |');
90
+ out.push('| --- | --- | --- | --- | --- | --- |');
91
+ for (const c of claims) {
92
+ const short = c.text.replace(/\|/g, '\\|').slice(0, 60);
93
+ const refsForClaim = refs.filter((r) => r.claimId === c.id && r.verdict === 'verified');
94
+ const refText = refsForClaim.length
95
+ ? refsForClaim.map((r) => (r.record.doi ? `doi:${r.record.doi}` : r.record.title.slice(0, 30))).join(';')
96
+ : '—';
97
+ out.push(`| ${c.id} | ${c.role === 'user' ? '用户' : 'AI'} | ${c.turn} | ${short} | ${refText} | ${c.status || 'candidate'} |`);
98
+ }
99
+ out.push('');
100
+ out.push('## 未被引用的对话结论');
101
+ out.push('');
102
+ const unused = claims.filter((c) => !refs.some((r) => r.claimId === c.id));
103
+ if (!unused.length) out.push('(无)');
104
+ else for (const c of unused) out.push(`- ${c.id} ${c.text.slice(0, 80)}`);
105
+ out.push('');
106
+ return out.join('\n');
107
+ }
108
+
109
+ export function buildBibliography(refs) {
110
+ const verified = refs.filter((r) => r.verdict === 'verified');
111
+ const possible = refs.filter((r) => r.verdict === 'possible');
112
+ const rejected = refs.filter((r) => r.verdict === 'rejected');
113
+ const out = [];
114
+ out.push('# 参考文献(GB/T 7714)');
115
+ out.push('');
116
+ out.push(`已核实 ${verified.length} 条 / 待核对 ${possible.length} 条 / 已拒绝 ${rejected.length} 条。`);
117
+ out.push('');
118
+ verified.forEach((r, i) => out.push(r.gbt7714.replace(/^\[\d+\]\s*/, `[${i + 1}] `)));
119
+ if (possible.length) {
120
+ out.push('');
121
+ out.push('## 待人工核对(不得直接引用)');
122
+ out.push('');
123
+ for (const r of possible) {
124
+ out.push(`- 输入:${r.input.title}`);
125
+ out.push(` 候选:${r.record.title}(${r.record.venue || '—'}, ${r.record.year || '—'})相似度 ${r.similarity}`);
126
+ if (r.record.doi) out.push(` DOI:${r.record.doi}`);
127
+ }
128
+ }
129
+ if (rejected.length) {
130
+ out.push('');
131
+ out.push('## 已拒绝(查无此文,模型可能编造)');
132
+ out.push('');
133
+ for (const r of rejected) out.push(`- ${r.input.title || '(空)'} —— ${r.reason}`);
134
+ }
135
+ out.push('');
136
+ return out.join('\n');
137
+ }
@@ -0,0 +1,170 @@
1
+ // Vendored copy of lib/claims.mjs — edit lib/ and run `node scripts/sync-core.mjs`.
2
+ /**
3
+ * Heuristic candidate extraction: which sentences carry a stance, a judgement,
4
+ * a conclusion, or an idea worth putting into a paper.
5
+ *
6
+ * This is deliberately conservative and cheap. The model curates the list
7
+ * afterwards (SKILL.md step 3) — the script's job is to make sure nothing is
8
+ * silently dropped, and to keep a stable id + provenance for every candidate.
9
+ */
10
+
11
+ const CLAIM_MARKERS = [
12
+ // 判断 / 归因 / 结论
13
+ '因此', '所以', '可见', '说明', '本质上', '关键是', '关键在于', '我认为', '我的判断',
14
+ '结论', '核心是', '根本原因', '之所以', '这意味着', '换句话说', '值得注意', '说白了',
15
+ '更重要的是', '问题在于', '真正的', '应该是', '不应该', '建议', '必须', '不能',
16
+ '只能', '代价', '前提', '边界', '而不是', '反过来', '实际上', '误区', '容易', '风险',
17
+ // 英文
18
+ 'therefore', 'thus', 'this means', 'the key', 'in my view', 'i think', 'suggests that',
19
+ 'implies', 'we should', 'the problem is', 'root cause', 'trade-off', 'rather than',
20
+ ];
21
+
22
+ const NOVELTY_MARKERS = [
23
+ // 创意 / 反直觉 / 框架 / 类比
24
+ '反直觉', '与直觉相反', '容易被忽略', '被低估', '重新定义', '换个角度', '打个比方',
25
+ '类比', '可以这样理解', '三个层次', '两层', '框架', '假设', '反例', '悖论', '张力',
26
+ 'insight', 'reframe', 'framework', 'counterintuitive', 'analogy', 'novel', 'hypothesis',
27
+ ];
28
+
29
+ const USER_INTENT_MARKERS = ['我想要', '我希望', '我担心', '困扰', '问题是', '我觉得', '能不能', '有没有'];
30
+
31
+ const MD_LINK = /\[([^\]]*)\]\([^)]*\)/g;
32
+ const MD_EMPH = /(\*\*|__|\*|_|`|~~)/g;
33
+
34
+ /** Strip markdown scaffolding so the extracted sentence reads like prose. */
35
+ export function cleanText(text) {
36
+ return String(text)
37
+ .replace(/```[\s\S]*?```/g, ' ')
38
+ .replace(/<[^>]+>/g, ' ')
39
+ .replace(MD_LINK, '$1')
40
+ .replace(MD_EMPH, '')
41
+ .replace(/^\s{0,3}#{1,6}\s+/gm, '')
42
+ .replace(/^\s{0,3}>\s?/gm, '')
43
+ .replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, '')
44
+ .replace(/\|/g, ' ')
45
+ .replace(/[ \t]+/g, ' ')
46
+ .trim();
47
+ }
48
+
49
+ function splitSentences(text) {
50
+ const cleaned = cleanText(text);
51
+ const chunks = cleaned.split(/(?<=[。!?!?;;])|\n+/);
52
+ return chunks
53
+ .map((s) => s.replace(/\s+/g, ' ').trim())
54
+ .filter((s) => s.length >= 14 && s.length <= 420)
55
+ .filter((s) => !/^(js|bash|json|text)?\s*$/i.test(s));
56
+ }
57
+
58
+ function score(sentence, pool) {
59
+ const lower = sentence.toLowerCase();
60
+ let hits = 0;
61
+ const matched = [];
62
+ for (const m of pool) {
63
+ if (lower.includes(m.toLowerCase())) {
64
+ hits += 1;
65
+ matched.push(m);
66
+ }
67
+ }
68
+ return { hits, matched };
69
+ }
70
+
71
+ export function extractClaims(conversation, { perTurn = 6 } = {}) {
72
+ const claims = [];
73
+ let n = 0;
74
+ const seen = new Set();
75
+ const add = (claim) => {
76
+ const key = claim.text.toLowerCase().replace(/\s+/g, '').slice(0, 120);
77
+ if (!key || seen.has(key)) return;
78
+ seen.add(key);
79
+ n += 1;
80
+ claims.push({ ...claim, id: `C${String(n).padStart(3, '0')}` });
81
+ };
82
+
83
+ for (const turn of conversation.turns) {
84
+ // 用户的原话就是论文的问题陈述,整段保留,不参与线索词筛选。
85
+ for (const u of turn.users) {
86
+ const text = cleanText(u.text).replace(/\s+/g, ' ').slice(0, 320);
87
+ if (text.length >= 8) {
88
+ add({
89
+ role: 'user',
90
+ turn: turn.turn,
91
+ at: u.at,
92
+ text,
93
+ claimScore: 0,
94
+ noveltyScore: 0,
95
+ markers: ['问题陈述'],
96
+ section: '1 引言',
97
+ kind: 'statement',
98
+ status: 'candidate',
99
+ });
100
+ }
101
+ }
102
+
103
+ const buckets = [
104
+ ...turn.assistant.map((a) => ({ role: 'assistant', text: a.text, at: a.at })),
105
+ ...turn.users.map((u) => ({ role: 'user', text: u.text, at: u.at })),
106
+ ];
107
+ for (const bucket of buckets) {
108
+ const scored = splitSentences(bucket.text).map((s) => {
109
+ const c = score(s, CLAIM_MARKERS);
110
+ const nv = score(s, NOVELTY_MARKERS);
111
+ const intent = bucket.role === 'user' ? score(s, USER_INTENT_MARKERS) : { hits: 0, matched: [] };
112
+ return {
113
+ text: s,
114
+ claimScore: c.hits,
115
+ claimMarkers: c.matched,
116
+ noveltyScore: nv.hits,
117
+ noveltyMarkers: nv.matched,
118
+ intentScore: intent.hits,
119
+ total: c.hits * 2 + nv.hits * 2 + intent.hits + Math.min(s.length, 120) / 200,
120
+ };
121
+ });
122
+ const picked = scored
123
+ .filter((s) => s.claimScore > 0 || s.noveltyScore > 0 || s.intentScore > 0)
124
+ .sort((a, b) => b.total - a.total)
125
+ .slice(0, perTurn);
126
+
127
+ for (const s of picked) {
128
+ add({
129
+ role: bucket.role,
130
+ turn: turn.turn,
131
+ at: bucket.at,
132
+ text: s.text,
133
+ claimScore: s.claimScore,
134
+ noveltyScore: s.noveltyScore,
135
+ markers: [...new Set([...s.claimMarkers, ...s.noveltyMarkers])],
136
+ section:
137
+ bucket.role === 'user'
138
+ ? '1 引言'
139
+ : s.noveltyScore > 0
140
+ ? '3 核心观点与框架'
141
+ : '4 论证与证据',
142
+ kind: 'sentence',
143
+ status: 'candidate',
144
+ });
145
+ }
146
+ }
147
+ }
148
+ return claims;
149
+ }
150
+
151
+ export function renderClaimsMarkdown(claims, conversation) {
152
+ const out = ['# 候选观点清单', ''];
153
+ out.push(`来源:${conversation.meta.title || '(未命名会话)'}|候选 ${claims.length} 条`, '');
154
+ out.push('> 这些是脚本按线索词捞出的候选句。写论文前请逐条判定:保留 / 合并 / 删除,把结论写回 claims.json 的 status 字段。');
155
+ out.push('');
156
+ const bySection = new Map();
157
+ for (const c of claims) {
158
+ if (!bySection.has(c.section)) bySection.set(c.section, []);
159
+ bySection.get(c.section).push(c);
160
+ }
161
+ for (const [section, list] of bySection) {
162
+ out.push(`## ${section}`, '');
163
+ for (const c of list) {
164
+ out.push(`- **${c.id}**(第${c.turn}轮·${c.role === 'user' ? '用户' : 'AI'},线索:${c.markers.join('/') || '—'})`);
165
+ out.push(` ${c.text}`);
166
+ }
167
+ out.push('');
168
+ }
169
+ return out.join('\n');
170
+ }