papergod 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 +21 -0
- package/README.md +244 -0
- package/ROADMAP.md +171 -0
- package/example/main.tex +360 -0
- package/frontend/src/components/ui/badge.jsx +5 -0
- package/frontend/src/components/ui/button.jsx +24 -0
- package/frontend/src/components/workbench.jsx +182 -0
- package/frontend/src/lib/utils.js +6 -0
- package/frontend/src/main.jsx +19 -0
- package/frontend/src/theme.css +256 -0
- package/frontend/vite.config.js +23 -0
- package/package.json +73 -0
- package/papergod-demo.png +0 -0
- package/public/app.js +5480 -0
- package/public/brand/papergod-logo.png +0 -0
- package/public/i18n.js +95 -0
- package/public/index.html +480 -0
- package/public/pdf-sentence-mapping.js +142 -0
- package/public/react/app.js +209 -0
- package/public/react/assets/addon-fit-YJmn1quW.js +12 -0
- package/public/react/assets/addon-web-links-BWjmmSgS.js +12 -0
- package/public/react/assets/main.css +32 -0
- package/public/react/assets/xterm-BqvuqXEL.js +27 -0
- package/public/style.css +1462 -0
- package/src/cli.js +128 -0
- package/src/server/agent-adapters.js +1240 -0
- package/src/server/agent-errors.js +105 -0
- package/src/server/agent-runtime.js +81 -0
- package/src/server/agent.js +173 -0
- package/src/server/app-version.js +86 -0
- package/src/server/change-history.js +114 -0
- package/src/server/document-structure.js +174 -0
- package/src/server/index.js +1442 -0
- package/src/server/latex-structure.js +344 -0
- package/src/server/latex.js +67 -0
- package/src/server/library-engine.js +193 -0
- package/src/server/library-files.js +134 -0
- package/src/server/literature-review.js +122 -0
- package/src/server/orchestration-engine.js +662 -0
- package/src/server/paragraph-analysis.js +300 -0
- package/src/server/project-resources.js +290 -0
- package/src/server/project-store.js +808 -0
- package/src/server/prompt-manifest.js +300 -0
- package/src/server/references.js +425 -0
- package/src/server/review-panel.js +263 -0
- package/src/server/revise-workflow.js +278 -0
- package/src/server/revision-engine.js +607 -0
- package/src/server/security.js +16 -0
- package/src/server/text-extraction.js +149 -0
- package/src/server/workspace-browser.js +49 -0
- package/src/server/workspace-registry.js +143 -0
- package/src/server/workspace-terminal.js +99 -0
- package/src/server/workspace.js +223 -0
- package/src/server/zotero.js +98 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
import { readFile } from 'fs/promises';
|
|
3
|
+
import { sanitizePath } from './security.js';
|
|
4
|
+
import { loadProject, updateProject } from './project-store.js';
|
|
5
|
+
import { createAgentRun, updateAgentRun } from './project-resources.js';
|
|
6
|
+
import { syncDocumentStructure } from './document-structure.js';
|
|
7
|
+
import { runAcademicReviewAgent } from './agent-adapters.js';
|
|
8
|
+
import { agentFailureAudit } from './agent-errors.js';
|
|
9
|
+
import { createRevisionPlan } from './revision-engine.js';
|
|
10
|
+
import { materializeLibraries } from './library-files.js';
|
|
11
|
+
|
|
12
|
+
const REVIEW_ROLES = ['methodology', 'statistics', 'writing', 'domain', 'reproducibility'];
|
|
13
|
+
const VERDICTS = ['accept', 'minor-revision', 'major-revision', 'reject'];
|
|
14
|
+
const SEVERITY_RANK = { info: 0, minor: 1, major: 2, critical: 3 };
|
|
15
|
+
|
|
16
|
+
export const REVIEWER_PROFILES = [
|
|
17
|
+
{ id: 'methodology', name: 'Methodology reviewer', role: 'methodology', focus: 'Research design, assumptions, validity, baselines, controls, and whether conclusions follow from the method.', prompt: '' },
|
|
18
|
+
{ id: 'statistics', name: 'Statistical reviewer', role: 'statistics', focus: 'Statistical design, uncertainty, effect sizes, power, multiple comparisons, and validity of quantitative claims.', prompt: '' },
|
|
19
|
+
{ id: 'writing', name: 'Academic writing reviewer', role: 'writing', focus: 'Clarity, structure, terminology, claim precision, academic style, and reader comprehension.', prompt: '' },
|
|
20
|
+
{ id: 'domain', name: 'Domain reviewer', role: 'domain', focus: 'Novelty, domain assumptions, related work, significance, and correctness from the target field perspective.', prompt: '' },
|
|
21
|
+
{ id: 'reproducibility', name: 'Reproducibility reviewer', role: 'reproducibility', focus: 'Data, code, parameters, environment, protocols, ablations, and information needed to reproduce results.', prompt: '' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_REVIEW_RUBRIC = [
|
|
25
|
+
{ id: 'rigor', title: 'Technical rigor', instruction: 'Check correctness, assumptions, design, and threats to validity.', weight: 1 },
|
|
26
|
+
{ id: 'evidence', title: 'Evidence and claims', instruction: 'Check whether every important claim is supported and calibrated.', weight: 1 },
|
|
27
|
+
{ id: 'clarity', title: 'Clarity and organization', instruction: 'Check structure, definitions, precision, and readability.', weight: 0.8 },
|
|
28
|
+
{ id: 'reproducibility', title: 'Reproducibility', instruction: 'Check whether a competent reader could reproduce the work.', weight: 1 },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function now() { return new Date().toISOString(); }
|
|
32
|
+
function id(prefix) { return `${prefix}_${randomUUID()}`; }
|
|
33
|
+
function hash(value) { return createHash('sha256').update(value).digest('hex'); }
|
|
34
|
+
function problem(message, status = 400) { const error = new Error(message); error.status = status; return error; }
|
|
35
|
+
function clean(value) { return typeof value === 'string' ? value.trim() : ''; }
|
|
36
|
+
|
|
37
|
+
function validateReviewer(input, index) {
|
|
38
|
+
const reviewer = {
|
|
39
|
+
id: clean(input?.id) || id('reviewer'), name: clean(input?.name), role: clean(input?.role),
|
|
40
|
+
focus: clean(input?.focus), prompt: clean(input?.prompt),
|
|
41
|
+
};
|
|
42
|
+
if (!reviewer.name || !reviewer.focus) throw problem(`reviewers[${index}] needs name and focus`);
|
|
43
|
+
if (!REVIEW_ROLES.includes(reviewer.role)) throw problem(`reviewers[${index}].role must be one of: ${REVIEW_ROLES.join(', ')}`);
|
|
44
|
+
return reviewer;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateRubric(input, index) {
|
|
48
|
+
const rubric = {
|
|
49
|
+
id: clean(input?.id) || id('rubric'), title: clean(input?.title), instruction: clean(input?.instruction),
|
|
50
|
+
weight: typeof input?.weight === 'number' ? input.weight : 1,
|
|
51
|
+
};
|
|
52
|
+
if (!rubric.title || !rubric.instruction) throw problem(`rubric[${index}] needs title and instruction`);
|
|
53
|
+
if (!Number.isFinite(rubric.weight) || rubric.weight <= 0 || rubric.weight > 10) throw problem(`rubric[${index}].weight must be greater than 0 and at most 10`);
|
|
54
|
+
return rubric;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getReviewerProfileCatalog() {
|
|
58
|
+
return { profiles: structuredClone(REVIEWER_PROFILES), defaultRubric: structuredClone(DEFAULT_REVIEW_RUBRIC), roles: [...REVIEW_ROLES] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function listReviewRounds(workspaceRoot, documentId) {
|
|
62
|
+
const reviews = (await loadProject(workspaceRoot)).reviews;
|
|
63
|
+
return documentId ? reviews.filter((item) => item.documentId === documentId) : reviews;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function createReviewRound(workspaceRoot, input = {}) {
|
|
67
|
+
const project = await loadProject(workspaceRoot);
|
|
68
|
+
if (!project.documents.some((item) => item.id === input.documentId)) throw problem('Document not found', 404);
|
|
69
|
+
const reviewers = Array.isArray(input.reviewers) ? input.reviewers.map(validateReviewer) : [];
|
|
70
|
+
const rubric = Array.isArray(input.rubric) ? input.rubric.map(validateRubric) : [];
|
|
71
|
+
if (!reviewers.length || reviewers.length > 8) throw problem('A review panel needs between 1 and 8 reviewers');
|
|
72
|
+
if (!rubric.length || rubric.length > 20) throw problem('A review rubric needs between 1 and 20 criteria');
|
|
73
|
+
if (new Set(reviewers.map((item) => item.id)).size !== reviewers.length) throw problem('Reviewer IDs must be unique');
|
|
74
|
+
if (new Set(rubric.map((item) => item.id)).size !== rubric.length) throw problem('Rubric IDs must be unique');
|
|
75
|
+
const timestamp = now();
|
|
76
|
+
const review = {
|
|
77
|
+
id: id('review'), documentId: input.documentId, name: clean(input.name) || 'Peer review round',
|
|
78
|
+
status: 'draft', provider: clean(input.provider) || 'mock', reviewers, rubric,
|
|
79
|
+
reports: [], items: [], synthesis: { summary: '', verdict: '', consensus: [], conflicts: [], priorities: [] },
|
|
80
|
+
createdAt: timestamp, updatedAt: timestamp,
|
|
81
|
+
};
|
|
82
|
+
if (!['mock', 'codex', 'claude-code', 'opencode', 'pi'].includes(review.provider)) throw problem('provider must be mock, codex, claude-code, opencode, or pi');
|
|
83
|
+
await updateProject(workspaceRoot, (draft) => draft.reviews.push(review));
|
|
84
|
+
return review;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function manuscriptSentences(content) {
|
|
88
|
+
return content.split(/(?<=[.!?。!?])\s+/).map((text) => text.trim()).filter((text) => text.length >= 20 && !/^\\(?:documentclass|usepackage)/.test(text));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function firstMatchingSentence(content, pattern) {
|
|
92
|
+
return manuscriptSentences(content).find((sentence) => pattern.test(sentence)) || manuscriptSentences(content)[0] || '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function generateMockPeerReview(content, reviewer, rubric) {
|
|
96
|
+
const rubricFor = (keyword, fallback = 0) => rubric.find((item) => item.id.toLowerCase().includes(keyword))?.id || rubric[fallback]?.id || rubric[0].id;
|
|
97
|
+
const lower = content.toLowerCase();
|
|
98
|
+
const items = [];
|
|
99
|
+
const add = (value) => items.push({ id: id('review_item'), ...value });
|
|
100
|
+
if (reviewer.role === 'methodology') {
|
|
101
|
+
const quote = firstMatchingSentence(content, /method|approach|algorithm|模型|方法|算法/i);
|
|
102
|
+
add({ rubricId: rubricFor('rigor'), kind: 'concern', category: 'method', severity: 'major', body: 'Clarify the design assumptions, comparison protocol, and threats to validity for the central method.', suggestedFix: '', quote });
|
|
103
|
+
add({ rubricId: rubricFor('evidence', 1), kind: 'strength', category: 'method', severity: 'info', body: 'The manuscript exposes a recognizable methodological contribution that can be evaluated explicitly.', suggestedFix: '', quote });
|
|
104
|
+
} else if (reviewer.role === 'statistics') {
|
|
105
|
+
const quote = firstMatchingSentence(content, /result|accuracy|significant|%|结果|准确|显著/i);
|
|
106
|
+
const hasStatistics = /confidence interval|standard deviation|p\s*[<=>]|effect size|置信区间|标准差|显著性/i.test(lower);
|
|
107
|
+
add({ rubricId: rubricFor('evidence', 1), kind: 'concern', category: 'evidence', severity: hasStatistics ? 'minor' : 'major', body: hasStatistics ? 'Report the statistical procedure and sample definition consistently for every quantitative comparison.' : 'Quantitative claims need uncertainty, sample sizes, and a justified statistical comparison.', suggestedFix: '', quote });
|
|
108
|
+
} else if (reviewer.role === 'writing') {
|
|
109
|
+
const imprecise = content.match(/\bvery\s+(?:important|useful|good|large)\b/i);
|
|
110
|
+
const quote = imprecise?.[0] || firstMatchingSentence(content, /conclusion|introduction|因此|本文|结论/i);
|
|
111
|
+
add({ rubricId: rubricFor('clarity', 2), kind: 'concern', category: 'style', severity: 'minor', body: 'Replace broad evaluative wording with a precise claim tied to the reported evidence.', suggestedFix: imprecise ? 'substantial' : '', quote });
|
|
112
|
+
} else if (reviewer.role === 'domain') {
|
|
113
|
+
const quote = firstMatchingSentence(content, /we (?:propose|present|show)|本文|我们提出|贡献/i);
|
|
114
|
+
add({ rubricId: rubricFor('evidence', 1), kind: 'concern', category: 'citation', severity: 'major', body: 'Position the central novelty against the closest domain-specific alternatives and state the practical boundary of the contribution.', suggestedFix: '', quote });
|
|
115
|
+
} else {
|
|
116
|
+
const quote = firstMatchingSentence(content, /experiment|implementation|dataset|实验|实现|数据/i);
|
|
117
|
+
const reproducible = /github|repository|code|seed|dataset|parameter|代码|仓库|随机种子|数据集|参数/i.test(lower);
|
|
118
|
+
add({ rubricId: rubricFor('reproducibility', 3), kind: 'concern', category: 'method', severity: reproducible ? 'minor' : 'major', body: reproducible ? 'Consolidate implementation details, versions, seeds, and data access instructions into a reproducibility checklist.' : 'Add code/data availability, parameter settings, environment versions, random seeds, and an executable reproduction protocol.', suggestedFix: '', quote });
|
|
119
|
+
}
|
|
120
|
+
const major = items.some((item) => SEVERITY_RANK[item.severity] >= SEVERITY_RANK.major && item.kind === 'concern');
|
|
121
|
+
return { summary: `${reviewer.name} identified ${items.filter((item) => item.kind === 'concern').length} actionable concern(s) from the ${reviewer.role} perspective.`, verdict: major ? 'major-revision' : 'minor-revision', confidence: 0.78, items };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeItem(item, reviewerId) {
|
|
125
|
+
return { ...item, id: item.id || id('review_item'), reviewerId };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function tokens(value) { return new Set(String(value || '').toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) || []); }
|
|
129
|
+
function similarity(left, right) {
|
|
130
|
+
const a = tokens(left); const b = tokens(right); if (!a.size || !b.size) return 0;
|
|
131
|
+
let shared = 0; a.forEach((token) => { if (b.has(token)) shared += 1; });
|
|
132
|
+
return shared / Math.min(a.size, b.size);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function synthesizePeerReviews(reports) {
|
|
136
|
+
const items = reports.flatMap((report) => report.items || []);
|
|
137
|
+
const groups = [];
|
|
138
|
+
for (const item of items) {
|
|
139
|
+
const group = groups.find((candidate) => candidate.category === item.category
|
|
140
|
+
&& ((item.quote && candidate.quote === item.quote) || similarity(candidate.body, item.body) >= 0.34));
|
|
141
|
+
if (group) { group.items.push(item); group.reviewerIds.add(item.reviewerId); }
|
|
142
|
+
else groups.push({ category: item.category, quote: item.quote, body: item.body, items: [item], reviewerIds: new Set([item.reviewerId]) });
|
|
143
|
+
}
|
|
144
|
+
const consensus = groups.filter((group) => group.reviewerIds.size >= 2 && group.items.every((item) => item.kind === group.items[0].kind)).map((group) => ({
|
|
145
|
+
id: id('consensus'), kind: group.items[0].kind, category: group.category, body: group.body,
|
|
146
|
+
itemIds: group.items.map((item) => item.id), reviewerIds: [...group.reviewerIds],
|
|
147
|
+
}));
|
|
148
|
+
const conflicts = groups.filter((group) => new Set(group.items.map((item) => item.kind)).size > 1
|
|
149
|
+
|| new Set(group.items.map((item) => item.suggestedFix).filter(Boolean)).size > 1).map((group) => ({
|
|
150
|
+
id: id('conflict'), category: group.category, quote: group.quote,
|
|
151
|
+
description: 'Reviewers disagree on the assessment or proposed resolution for the same manuscript area.',
|
|
152
|
+
itemIds: group.items.map((item) => item.id), reviewerIds: [...group.reviewerIds],
|
|
153
|
+
}));
|
|
154
|
+
const priorities = items.filter((item) => item.kind === 'concern').sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]).slice(0, 12).map((item) => item.id);
|
|
155
|
+
const verdictCounts = Object.fromEntries(VERDICTS.map((verdict) => [verdict, reports.filter((report) => report.verdict === verdict).length]));
|
|
156
|
+
const verdict = [...VERDICTS].reverse().find((candidate) => verdictCounts[candidate]) || 'accept';
|
|
157
|
+
return {
|
|
158
|
+
summary: `${reports.length} independent report(s), ${items.length} finding(s), ${consensus.length} consensus cluster(s), and ${conflicts.length} conflict(s).`,
|
|
159
|
+
verdict, consensus, conflicts, priorities,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function runOneReviewer(workspaceRoot, provider, content, reviewer, rubric, options, file = '') {
|
|
164
|
+
const startedAt = now();
|
|
165
|
+
const run = await createAgentRun(workspaceRoot, {
|
|
166
|
+
provider, operation: 'peer-review', status: provider === 'mock' ? 'queued' : 'running',
|
|
167
|
+
prompt: JSON.stringify({ reviewer, rubric }), input: JSON.stringify({ characters: content.length, reviewerId: reviewer.id }),
|
|
168
|
+
output: '', error: '', startedAt, finishedAt: '',
|
|
169
|
+
});
|
|
170
|
+
try {
|
|
171
|
+
const result = provider === 'mock' ? generateMockPeerReview(content, reviewer, rubric)
|
|
172
|
+
: await runAcademicReviewAgent(provider, {
|
|
173
|
+
content, reviewer, rubric,
|
|
174
|
+
workspace: file ? { file, start: 0, end: content.length } : null,
|
|
175
|
+
}, options);
|
|
176
|
+
const report = {
|
|
177
|
+
id: id('review_report'), reviewerId: reviewer.id, runId: run.id, status: 'complete',
|
|
178
|
+
summary: result.summary, verdict: result.verdict, confidence: result.confidence,
|
|
179
|
+
items: result.items.map((item) => normalizeItem(item, reviewer.id)), error: '', createdAt: now(),
|
|
180
|
+
};
|
|
181
|
+
await updateAgentRun(workspaceRoot, run.id, { status: 'complete', output: JSON.stringify({ ...result, agentMeta: result.agentMeta }), finishedAt: now() });
|
|
182
|
+
return report;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
await updateAgentRun(workspaceRoot, run.id, { status: 'failed', error: agentFailureAudit(error), finishedAt: now() });
|
|
185
|
+
return { id: id('review_report'), reviewerId: reviewer.id, runId: run.id, status: 'failed', summary: '', verdict: 'reject', confidence: 0, items: [], error: error.message.slice(0, 4000), createdAt: now() };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function runReviewRound(workspaceRoot, reviewId, options = {}) {
|
|
190
|
+
let project = await loadProject(workspaceRoot);
|
|
191
|
+
let review = project.reviews.find((item) => item.id === reviewId);
|
|
192
|
+
if (!review) throw problem('Review round not found', 404);
|
|
193
|
+
if (review.status === 'running') throw problem('Review round is already running', 409);
|
|
194
|
+
const document = project.documents.find((item) => item.id === review.documentId);
|
|
195
|
+
if (!document) throw problem('Document not found', 404);
|
|
196
|
+
await syncDocumentStructure(workspaceRoot, document.file);
|
|
197
|
+
project = await loadProject(workspaceRoot);
|
|
198
|
+
review = project.reviews.find((item) => item.id === reviewId);
|
|
199
|
+
const currentDocument = project.documents.find((item) => item.id === review.documentId);
|
|
200
|
+
const file = sanitizePath(currentDocument.file, workspaceRoot);
|
|
201
|
+
if (!file) throw problem('Access denied', 403);
|
|
202
|
+
const content = await readFile(file, 'utf-8');
|
|
203
|
+
if (currentDocument.sourceHash !== hash(content)) throw problem('Document structure is stale', 409);
|
|
204
|
+
await updateProject(workspaceRoot, (draft) => {
|
|
205
|
+
const current = draft.reviews.find((item) => item.id === reviewId);
|
|
206
|
+
current.status = 'running'; current.reports = []; current.items = [];
|
|
207
|
+
current.synthesis = { summary: '', verdict: '', consensus: [], conflicts: [], priorities: [] }; current.updatedAt = now();
|
|
208
|
+
});
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
options.signal?.addEventListener('abort', () => controller.abort(), { once: true });
|
|
211
|
+
if (review.provider !== 'mock') await materializeLibraries(workspaceRoot);
|
|
212
|
+
const reports = await Promise.all(review.reviewers.map((reviewer) => runOneReviewer(workspaceRoot, review.provider, content, reviewer, review.rubric, {
|
|
213
|
+
workspaceRoot, commands: options.commands || {}, signal: controller.signal,
|
|
214
|
+
}, currentDocument.file)));
|
|
215
|
+
const successful = reports.filter((report) => report.status === 'complete');
|
|
216
|
+
const synthesis = synthesizePeerReviews(successful);
|
|
217
|
+
const { result } = await updateProject(workspaceRoot, (draft) => {
|
|
218
|
+
const current = draft.reviews.find((item) => item.id === reviewId);
|
|
219
|
+
current.status = successful.length ? 'complete' : 'failed'; current.reports = reports;
|
|
220
|
+
current.items = successful.flatMap((report) => report.items); current.synthesis = synthesis; current.updatedAt = now();
|
|
221
|
+
return structuredClone(current);
|
|
222
|
+
});
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function flattenNodes(document) {
|
|
227
|
+
const nodes = [];
|
|
228
|
+
const visit = (items) => (items || []).forEach((node) => { nodes.push(node); visit(node.children); });
|
|
229
|
+
visit(document.sections); return nodes;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function targetForItem(document, content, item) {
|
|
233
|
+
if (!item.quote) return { type: 'document', id: document.id, start: 0, end: 0, quote: '' };
|
|
234
|
+
const start = content.indexOf(item.quote); const end = start < 0 ? 0 : start + item.quote.length;
|
|
235
|
+
const node = start < 0 ? null : flattenNodes(document).filter((candidate) => candidate.sourceRange?.start <= start && candidate.sourceRange?.end >= end)
|
|
236
|
+
.sort((a, b) => (a.sourceRange.end - a.sourceRange.start) - (b.sourceRange.end - b.sourceRange.start))[0];
|
|
237
|
+
return { type: start < 0 ? 'document' : 'range', id: node?.id || document.id, start: Math.max(0, start), end, quote: start < 0 ? '' : item.quote };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function sendReviewItemsToRevision(workspaceRoot, reviewId, itemIds, title = '') {
|
|
241
|
+
const project = await loadProject(workspaceRoot);
|
|
242
|
+
const review = project.reviews.find((item) => item.id === reviewId);
|
|
243
|
+
if (!review) throw problem('Review round not found', 404);
|
|
244
|
+
if (review.status !== 'complete') throw problem('Only a completed review can enter revision planning', 409);
|
|
245
|
+
const selectedIds = Array.isArray(itemIds) && itemIds.length ? itemIds : review.synthesis.priorities;
|
|
246
|
+
const selected = review.items.filter((item) => selectedIds.includes(item.id) && item.kind === 'concern');
|
|
247
|
+
if (!selected.length) throw problem('Select at least one concern');
|
|
248
|
+
const document = project.documents.find((item) => item.id === review.documentId);
|
|
249
|
+
const file = sanitizePath(document.file, workspaceRoot); if (!file) throw problem('Access denied', 403);
|
|
250
|
+
const content = await readFile(file, 'utf-8');
|
|
251
|
+
const timestamp = now();
|
|
252
|
+
const annotations = selected.map((item, index) => ({
|
|
253
|
+
id: id('annotation'), documentId: document.id, order: index + 1, target: targetForItem(document, content, item),
|
|
254
|
+
category: item.category, severity: item.severity, body: item.body, suggestedFix: item.suggestedFix,
|
|
255
|
+
status: 'open', source: { type: 'reviewer', actor: review.reviewers.find((reviewer) => reviewer.id === item.reviewerId)?.name || item.reviewerId },
|
|
256
|
+
createdAt: timestamp, updatedAt: timestamp,
|
|
257
|
+
}));
|
|
258
|
+
await updateProject(workspaceRoot, (draft) => draft.annotations.push(...annotations));
|
|
259
|
+
const revision = await createRevisionPlan(workspaceRoot, {
|
|
260
|
+
documentId: document.id, annotationIds: annotations.map((item) => item.id), title: clean(title) || `${review.name} revision`,
|
|
261
|
+
});
|
|
262
|
+
return { annotations, revision };
|
|
263
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { readFile } from 'fs/promises';
|
|
3
|
+
import { compile } from './latex.js';
|
|
4
|
+
import { sanitizePath } from './security.js';
|
|
5
|
+
import { loadProject, updateProject } from './project-store.js';
|
|
6
|
+
import { createAgentRun, updateAgentRun } from './project-resources.js';
|
|
7
|
+
import { syncDocumentStructure } from './document-structure.js';
|
|
8
|
+
import { buildLibraryContext, composeMockParagraph } from './library-engine.js';
|
|
9
|
+
import { runPaperGenerationAgent, validatePaperGenerationResponse } from './agent-adapters.js';
|
|
10
|
+
import { agentFailureAudit } from './agent-errors.js';
|
|
11
|
+
|
|
12
|
+
function now() { return new Date().toISOString(); }
|
|
13
|
+
function id(prefix) { return `${prefix}_${randomUUID()}`; }
|
|
14
|
+
function problem(message, status = 400) { const error = new Error(message); error.status = status; return error; }
|
|
15
|
+
function clean(value) { return typeof value === 'string' ? value.trim() : ''; }
|
|
16
|
+
|
|
17
|
+
function targetLocation(target = {}) {
|
|
18
|
+
if (target.type === 'range') return `characters ${target.start}–${target.end}`;
|
|
19
|
+
return target.type || 'document';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function decisionForAnnotation(revision, annotationId) {
|
|
23
|
+
return revision.changes.find((change) => change.annotationId === annotationId);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildRevisionPackage(revision, annotations) {
|
|
27
|
+
const related = revision.annotationIds.map((annotationId) => annotations.find((item) => item.id === annotationId)).filter(Boolean);
|
|
28
|
+
const items = related.map((annotation, index) => {
|
|
29
|
+
const change = decisionForAnnotation(revision, annotation.id);
|
|
30
|
+
const status = change?.status || annotation.status;
|
|
31
|
+
let response;
|
|
32
|
+
if (['applied', 'accepted'].includes(status)) {
|
|
33
|
+
response = change?.after
|
|
34
|
+
? `We addressed this comment by revising “${change.before}” to “${change.after}”.`
|
|
35
|
+
: 'We addressed this comment in the revised manuscript.';
|
|
36
|
+
} else if (status === 'rejected') response = 'We respectfully did not make this change; the rationale should be completed by the author.';
|
|
37
|
+
else if (status === 'deferred') response = 'This comment is deferred and remains to be addressed.';
|
|
38
|
+
else response = 'This comment remains open and requires an author response.';
|
|
39
|
+
return {
|
|
40
|
+
annotationId: annotation.id, order: annotation.order || index + 1, opinion: annotation.body,
|
|
41
|
+
response, status, location: targetLocation(annotation.target),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
const changeList = revision.changes.map((change, index) => ({
|
|
45
|
+
changeId: change.id, annotationId: change.annotationId || '', order: index + 1, status: change.status,
|
|
46
|
+
location: targetLocation(change.target), before: change.before, after: change.after, reason: change.reason,
|
|
47
|
+
}));
|
|
48
|
+
return {
|
|
49
|
+
responseLetter: {
|
|
50
|
+
title: `Response to comments — ${revision.title}`,
|
|
51
|
+
introduction: 'We thank the reviewers for their constructive feedback. Responses and manuscript changes are listed point by point below.',
|
|
52
|
+
items,
|
|
53
|
+
},
|
|
54
|
+
changeList,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function generateRevisionPackage(workspaceRoot, revisionId) {
|
|
59
|
+
const { result } = await updateProject(workspaceRoot, (project) => {
|
|
60
|
+
const revision = project.revisions.find((item) => item.id === revisionId);
|
|
61
|
+
if (!revision) throw problem('Revision not found', 404);
|
|
62
|
+
const generated = buildRevisionPackage(revision, project.annotations);
|
|
63
|
+
if (revision.responseLetter) {
|
|
64
|
+
generated.responseLetter.introduction = revision.responseLetter.introduction;
|
|
65
|
+
const priorResponses = new Map(revision.responseLetter.items.map((item) => [item.annotationId, item.response]));
|
|
66
|
+
generated.responseLetter.items.forEach((item) => {
|
|
67
|
+
if (priorResponses.has(item.annotationId)) item.response = priorResponses.get(item.annotationId);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
revision.responseLetter = generated.responseLetter;
|
|
71
|
+
revision.changeList = generated.changeList;
|
|
72
|
+
revision.updatedAt = now();
|
|
73
|
+
return { revision: structuredClone(revision), ...structuredClone(generated) };
|
|
74
|
+
});
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function updateRevisionResponseLetter(workspaceRoot, revisionId, input = {}) {
|
|
79
|
+
const { result } = await updateProject(workspaceRoot, (project) => {
|
|
80
|
+
const revision = project.revisions.find((item) => item.id === revisionId);
|
|
81
|
+
if (!revision) throw problem('Revision not found', 404);
|
|
82
|
+
if (!revision.responseLetter) throw problem('Generate a response letter first', 409);
|
|
83
|
+
if (input.introduction !== undefined) {
|
|
84
|
+
if (typeof input.introduction !== 'string') throw problem('introduction must be a string');
|
|
85
|
+
revision.responseLetter.introduction = input.introduction;
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(input.items)) {
|
|
88
|
+
for (const patch of input.items) {
|
|
89
|
+
const item = revision.responseLetter.items.find((candidate) => candidate.annotationId === patch.annotationId);
|
|
90
|
+
if (!item) throw problem(`Response item not found: ${patch.annotationId}`, 404);
|
|
91
|
+
if (typeof patch.response !== 'string' || !patch.response.trim()) throw problem('Each response must be non-empty');
|
|
92
|
+
item.response = patch.response;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
revision.updatedAt = now();
|
|
96
|
+
return structuredClone(revision.responseLetter);
|
|
97
|
+
});
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function verifyAppliedRevision(workspaceRoot, revisionId) {
|
|
102
|
+
const project = await loadProject(workspaceRoot);
|
|
103
|
+
const revision = project.revisions.find((item) => item.id === revisionId);
|
|
104
|
+
if (!revision) throw problem('Revision not found', 404);
|
|
105
|
+
if (revision.status !== 'applied') throw problem('Apply the revision before verification', 409);
|
|
106
|
+
const document = project.documents.find((item) => item.id === revision.documentId);
|
|
107
|
+
const file = sanitizePath(document.file, workspaceRoot);
|
|
108
|
+
if (!file) throw problem('Access denied', 403);
|
|
109
|
+
const compileResult = await compile(file, workspaceRoot);
|
|
110
|
+
const refreshed = await loadProject(workspaceRoot);
|
|
111
|
+
const unresolved = refreshed.annotations.filter((item) => item.documentId === document.id && !['resolved', 'rejected'].includes(item.status));
|
|
112
|
+
const verification = {
|
|
113
|
+
checkedAt: now(), compile: {
|
|
114
|
+
ok: compileResult.ok, engine: compileResult.engine || '', error: compileResult.ok ? '' : String(compileResult.error || '').slice(0, 4000),
|
|
115
|
+
},
|
|
116
|
+
unresolvedAnnotationIds: unresolved.map((item) => item.id),
|
|
117
|
+
complete: Boolean(compileResult.ok && unresolved.length === 0),
|
|
118
|
+
};
|
|
119
|
+
await updateProject(workspaceRoot, (draft) => {
|
|
120
|
+
const current = draft.revisions.find((item) => item.id === revisionId);
|
|
121
|
+
current.verification = verification; current.updatedAt = now();
|
|
122
|
+
});
|
|
123
|
+
return { verification, unresolved };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function escapeLatex(value) {
|
|
127
|
+
return String(value || '').replace(/\\/g, '\\textbackslash{}').replace(/([#$%&_{}])/g, '\\$1').replace(/~/g, '\\textasciitilde{}').replace(/\^/g, '\\textasciicircum{}');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function outlineContext(document) {
|
|
131
|
+
const lines = [];
|
|
132
|
+
for (const section of document.sections || []) {
|
|
133
|
+
lines.push(`SECTION: ${section.title}\nSummary: ${section.summary || ''}\nPrompt: ${section.prompt || ''}`);
|
|
134
|
+
for (const [index, paragraph] of (section.children || []).entries()) {
|
|
135
|
+
lines.push(` PARAGRAPH ${index + 1}: ${paragraph.summary || ''}\n Prompt: ${paragraph.prompt || ''}`);
|
|
136
|
+
for (const sentence of paragraph.children || []) if (sentence.intent) lines.push(` Sentence intent: ${sentence.intent}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function composeMockPaper(project, document, libraries, libraryContext, instruction) {
|
|
143
|
+
const title = document.title || project.name || 'Generated Paper';
|
|
144
|
+
const sections = document.sections?.length ? document.sections : [
|
|
145
|
+
{ title: 'Introduction', prompt: 'Present the problem, gap, and contributions.', children: [] },
|
|
146
|
+
{ title: 'Methods', prompt: 'Describe the method and assumptions.', children: [] },
|
|
147
|
+
{ title: 'Results', prompt: 'Report evidence and calibrated findings.', children: [] },
|
|
148
|
+
{ title: 'Conclusion', prompt: 'Summarize contributions, limitations, and future work.', children: [] },
|
|
149
|
+
];
|
|
150
|
+
const used = new Set();
|
|
151
|
+
const sectionLatex = sections.map((section) => {
|
|
152
|
+
const paragraphPrompts = section.children?.length
|
|
153
|
+
? section.children.map((paragraph) => paragraph.prompt || paragraph.summary || paragraph.text)
|
|
154
|
+
: [section.prompt || section.summary || `Develop the ${section.title} section.`];
|
|
155
|
+
const paragraphs = paragraphPrompts.map((prompt) => {
|
|
156
|
+
const composed = composeMockParagraph(libraries, libraryContext, [instruction, prompt].filter(Boolean).join(' '));
|
|
157
|
+
composed.usedResourceIds.forEach((resourceId) => used.add(resourceId));
|
|
158
|
+
return escapeLatex(composed.draft);
|
|
159
|
+
});
|
|
160
|
+
return `\\section{${escapeLatex(section.title)}}\n${paragraphs.join('\n\n')}`;
|
|
161
|
+
}).join('\n\n');
|
|
162
|
+
const abstractGoal = clean(document.corePrompt) || clean(project.corePrompt) || clean(instruction) || 'Summarize the research problem, approach, evidence, and contribution.';
|
|
163
|
+
const latex = `\\documentclass{article}
|
|
164
|
+
\\usepackage[utf8]{inputenc}
|
|
165
|
+
\\usepackage{amsmath}
|
|
166
|
+
\\title{${escapeLatex(title)}}
|
|
167
|
+
\\author{}
|
|
168
|
+
\\date{}
|
|
169
|
+
|
|
170
|
+
\\begin{document}
|
|
171
|
+
\\maketitle
|
|
172
|
+
|
|
173
|
+
\\begin{abstract}
|
|
174
|
+
${escapeLatex(`Draft objective: ${abstractGoal}`)}
|
|
175
|
+
\\end{abstract}
|
|
176
|
+
|
|
177
|
+
${sectionLatex}
|
|
178
|
+
|
|
179
|
+
\\end{document}
|
|
180
|
+
`;
|
|
181
|
+
return { summary: `Generated a complete draft with ${sections.length} section(s) from the structured writing context.`, latex, usedResourceIds: [...used] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function generatePaperRevision(workspaceRoot, input = {}, options = {}) {
|
|
185
|
+
let project = await loadProject(workspaceRoot);
|
|
186
|
+
const document = project.documents.find((item) => item.id === input.documentId);
|
|
187
|
+
if (!document) throw problem('Document not found', 404);
|
|
188
|
+
await syncDocumentStructure(workspaceRoot, document.file);
|
|
189
|
+
project = await loadProject(workspaceRoot);
|
|
190
|
+
const currentDocument = project.documents.find((item) => item.id === input.documentId);
|
|
191
|
+
const file = sanitizePath(currentDocument.file, workspaceRoot);
|
|
192
|
+
if (!file) throw problem('Access denied', 403);
|
|
193
|
+
const original = await readFile(file, 'utf-8');
|
|
194
|
+
const instruction = clean(input.instruction);
|
|
195
|
+
if (!instruction && !clean(project.project.corePrompt) && !clean(currentDocument.corePrompt)) throw problem('Add a generation instruction or a core prompt');
|
|
196
|
+
const libraryContext = buildLibraryContext(project.libraries, {
|
|
197
|
+
query: [instruction, project.project.corePrompt, currentDocument.corePrompt].filter(Boolean).join(' '),
|
|
198
|
+
resourceIds: Array.isArray(input.resourceIds) ? input.resourceIds : [],
|
|
199
|
+
});
|
|
200
|
+
const provider = options.provider || 'mock';
|
|
201
|
+
const startedAt = now();
|
|
202
|
+
const run = await createAgentRun(workspaceRoot, {
|
|
203
|
+
provider, operation: 'generate-paper', status: provider === 'mock' ? 'queued' : 'running', prompt: instruction,
|
|
204
|
+
input: JSON.stringify({ documentId: currentDocument.id, providedResources: libraryContext.resources }), output: '', error: '', startedAt, finishedAt: '',
|
|
205
|
+
});
|
|
206
|
+
try {
|
|
207
|
+
const request = {
|
|
208
|
+
instruction,
|
|
209
|
+
projectContext: `Project prompt: ${project.project.corePrompt}\nDocument prompt: ${currentDocument.corePrompt}\nDocument summary: ${currentDocument.summary}`,
|
|
210
|
+
outlineContext: outlineContext(currentDocument), resourceContext: libraryContext.prompt, resourceIds: libraryContext.resourceIds,
|
|
211
|
+
};
|
|
212
|
+
const generated = provider === 'mock'
|
|
213
|
+
? composeMockPaper(project.project, currentDocument, project.libraries, libraryContext, instruction)
|
|
214
|
+
: await runPaperGenerationAgent(provider, request, { workspaceRoot, commands: options.commands || {}, signal: options.signal });
|
|
215
|
+
const validation = validatePaperGenerationResponse(generated, libraryContext.resourceIds);
|
|
216
|
+
if (!validation.ok) throw problem(`Generated paper failed validation: ${validation.errors.join('; ')}`, 502);
|
|
217
|
+
await updateAgentRun(workspaceRoot, run.id, { status: 'complete', output: JSON.stringify({ summary: generated.summary, usedResourceIds: generated.usedResourceIds, characters: generated.latex.length, agentMeta: generated.agentMeta }), finishedAt: now() });
|
|
218
|
+
const timestamp = now();
|
|
219
|
+
const changeId = id('change');
|
|
220
|
+
const revision = {
|
|
221
|
+
id: id('revision'), documentId: currentDocument.id, file: currentDocument.file,
|
|
222
|
+
title: clean(input.title) || 'Generated full-paper draft', summary: generated.summary, status: 'review', annotationIds: [],
|
|
223
|
+
changes: [{
|
|
224
|
+
id: changeId, target: { type: 'range', id: currentDocument.id, start: 0, end: original.length, quote: original },
|
|
225
|
+
before: original, after: generated.latex, reason: 'Generate a complete paper from the project prompt, structured outline, element prompts, and writing libraries.',
|
|
226
|
+
status: 'proposed', executable: original !== generated.latex, dependsOn: [], conflictsWith: [],
|
|
227
|
+
}],
|
|
228
|
+
graph: { nodes: [changeId], edges: [] }, recoveryPoint: null, origin: 'paper-generation',
|
|
229
|
+
generation: { runId: run.id, instruction, providedResourceIds: libraryContext.resourceIds, usedResourceIds: generated.usedResourceIds },
|
|
230
|
+
createdAt: timestamp, updatedAt: timestamp,
|
|
231
|
+
};
|
|
232
|
+
await updateProject(workspaceRoot, (draft) => draft.revisions.push(revision));
|
|
233
|
+
return { revision, draft: generated.latex, runId: run.id, library: { mode: libraryContext.mode, providedResources: libraryContext.resources, usedResourceIds: generated.usedResourceIds } };
|
|
234
|
+
} catch (error) {
|
|
235
|
+
await updateAgentRun(workspaceRoot, run.id, { status: 'failed', error: agentFailureAudit(error), finishedAt: now() });
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function getWorkflowHistory(workspaceRoot, documentId) {
|
|
241
|
+
const project = await loadProject(workspaceRoot);
|
|
242
|
+
const events = [
|
|
243
|
+
...project.revisions.filter((item) => !documentId || item.documentId === documentId).map((item) => ({ id: item.id, type: 'revision', status: item.status, title: item.title, at: item.updatedAt, detail: item.summary })),
|
|
244
|
+
...project.reviews.filter((item) => !documentId || item.documentId === documentId).map((item) => ({ id: item.id, type: 'peer-review', status: item.status, title: item.name, at: item.updatedAt, detail: item.synthesis?.summary || '' })),
|
|
245
|
+
...project.agentRuns.map((item) => ({ id: item.id, type: 'agent-run', status: item.status, title: `${item.provider} · ${item.operation}`, at: item.updatedAt, detail: item.error || '' })),
|
|
246
|
+
].sort((a, b) => b.at.localeCompare(a.at));
|
|
247
|
+
return events;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function responseLetterMarkdown(revision) {
|
|
251
|
+
const letter = revision.responseLetter;
|
|
252
|
+
if (!letter) return '';
|
|
253
|
+
return `# ${letter.title}\n\n${letter.introduction}\n\n${letter.items.map((item) => `## Comment ${item.order}\n\n> ${item.opinion}\n\n**Response (${item.status}, ${item.location}):** ${item.response}`).join('\n\n')}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function changeListMarkdown(revision) {
|
|
257
|
+
const list = revision.changeList || buildRevisionPackage(revision, []).changeList;
|
|
258
|
+
return `# Change list — ${revision.title}\n\n${list.map((item) => `## Change ${item.order} — ${item.status}\n\n- Location: ${item.location}\n- Reason: ${item.reason}\n- Before: ${item.before || '(none)'}\n- After: ${item.after || '(none)'}`).join('\n\n')}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function buildWorkflowExport(workspaceRoot, documentId) {
|
|
262
|
+
const project = await loadProject(workspaceRoot);
|
|
263
|
+
const document = project.documents.find((item) => item.id === documentId);
|
|
264
|
+
if (!document) throw problem('Document not found', 404);
|
|
265
|
+
const file = sanitizePath(document.file, workspaceRoot); if (!file) throw problem('Access denied', 403);
|
|
266
|
+
const revisions = project.revisions.filter((item) => item.documentId === documentId);
|
|
267
|
+
return {
|
|
268
|
+
exportedAt: now(), project: project.project, document, source: await readFile(file, 'utf-8'),
|
|
269
|
+
annotations: project.annotations.filter((item) => item.documentId === documentId),
|
|
270
|
+
reviews: project.reviews.filter((item) => item.documentId === documentId), revisions,
|
|
271
|
+
agentRuns: project.agentRuns,
|
|
272
|
+
history: await getWorkflowHistory(workspaceRoot, documentId),
|
|
273
|
+
artifacts: revisions.map((revision) => ({
|
|
274
|
+
revisionId: revision.id, responseLetterMarkdown: responseLetterMarkdown(revision), changeListMarkdown: changeListMarkdown(revision),
|
|
275
|
+
recoveryPoint: revision.recoveryPoint,
|
|
276
|
+
})),
|
|
277
|
+
};
|
|
278
|
+
}
|