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,344 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
|
|
3
|
+
const SECTION_LEVELS = { section: 1, subsection: 2, subsubsection: 3 };
|
|
4
|
+
|
|
5
|
+
function id(prefix) {
|
|
6
|
+
return `${prefix}_${randomUUID()}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalize(value) {
|
|
10
|
+
return value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fingerprint(value) {
|
|
14
|
+
return createHash('sha1').update(normalize(value)).digest('hex').slice(0, 12);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isEscaped(source, index) {
|
|
18
|
+
let slashes = 0;
|
|
19
|
+
for (let cursor = index - 1; cursor >= 0 && source[cursor] === '\\'; cursor -= 1) slashes += 1;
|
|
20
|
+
return slashes % 2 === 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isCommented(source, index) {
|
|
24
|
+
const lineStart = source.lastIndexOf('\n', index - 1) + 1;
|
|
25
|
+
for (let cursor = lineStart; cursor < index; cursor += 1) {
|
|
26
|
+
if (source[cursor] === '%' && !isEscaped(source, cursor)) return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function closingBrace(source, openIndex) {
|
|
32
|
+
let depth = 0;
|
|
33
|
+
for (let cursor = openIndex; cursor < source.length; cursor += 1) {
|
|
34
|
+
if (source[cursor] === '%' && !isEscaped(source, cursor)) {
|
|
35
|
+
const newline = source.indexOf('\n', cursor);
|
|
36
|
+
if (newline === -1) return -1;
|
|
37
|
+
cursor = newline;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (source[cursor] === '{' && !isEscaped(source, cursor)) depth += 1;
|
|
41
|
+
if (source[cursor] === '}' && !isEscaped(source, cursor)) {
|
|
42
|
+
depth -= 1;
|
|
43
|
+
if (depth === 0) return cursor;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return -1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function findCommandValue(source, command, from = 0, to = source.length) {
|
|
50
|
+
const regex = new RegExp(`\\\\${command}\\*?\\s*\\{`, 'g');
|
|
51
|
+
regex.lastIndex = from;
|
|
52
|
+
let match;
|
|
53
|
+
while ((match = regex.exec(source)) && match.index < to) {
|
|
54
|
+
if (isCommented(source, match.index)) continue;
|
|
55
|
+
const open = match.index + match[0].lastIndexOf('{');
|
|
56
|
+
const close = closingBrace(source, open);
|
|
57
|
+
if (close !== -1 && close < to) {
|
|
58
|
+
return { value: source.slice(open + 1, close), start: match.index, end: close + 1 };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function findHeadings(source, from, to) {
|
|
65
|
+
const regex = /\\(section|subsection|subsubsection)\*?\s*\{/g;
|
|
66
|
+
regex.lastIndex = from;
|
|
67
|
+
const headings = [];
|
|
68
|
+
let match;
|
|
69
|
+
while ((match = regex.exec(source)) && match.index < to) {
|
|
70
|
+
if (isCommented(source, match.index)) continue;
|
|
71
|
+
const open = match.index + match[0].lastIndexOf('{');
|
|
72
|
+
const close = closingBrace(source, open);
|
|
73
|
+
if (close === -1 || close >= to) continue;
|
|
74
|
+
headings.push({
|
|
75
|
+
command: match[1], level: SECTION_LEVELS[match[1]], title: source.slice(open + 1, close).trim(),
|
|
76
|
+
start: match.index, headingEnd: close + 1,
|
|
77
|
+
});
|
|
78
|
+
regex.lastIndex = close + 1;
|
|
79
|
+
}
|
|
80
|
+
return headings;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function trimRange(source, start, end) {
|
|
84
|
+
while (start < end && /\s/.test(source[start])) start += 1;
|
|
85
|
+
while (end > start && /\s/.test(source[end - 1])) end -= 1;
|
|
86
|
+
return { start, end };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function plainText(raw) {
|
|
90
|
+
return raw
|
|
91
|
+
.replace(/(?<!\\)%.*$/gm, ' ')
|
|
92
|
+
.replace(/\\(?:cite|ref|label|footnote|emph|textbf|textit)\*?(?:\[[^\]]*\])?\{([^{}]*)\}/g, '$1')
|
|
93
|
+
.replace(/\\[a-zA-Z@]+\*?(?:\[[^\]]*\])?/g, ' ')
|
|
94
|
+
.replace(/[{}]/g, ' ')
|
|
95
|
+
.replace(/\$+[^$]*\$+/g, ' equation ')
|
|
96
|
+
.replace(/\s+/g, ' ')
|
|
97
|
+
.trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Common Latin/English abbreviations whose trailing period is not a sentence
|
|
101
|
+
// boundary. Keys include multi-character sequences such as "e.g" where the
|
|
102
|
+
// intermediate period is part of the token.
|
|
103
|
+
export const SENTENCE_ABBREVIATIONS = new Set([
|
|
104
|
+
'e.g', 'i.e', 'etc', 'cf', 'vs', 'viz', 'ca', 'approx', 'resp', 'ibid', 'loc', 'cit', 'op',
|
|
105
|
+
'fig', 'figs', 'sec', 'secs', 'eq', 'eqn', 'eqns', 'ref', 'refs', 'no', 'nos', 'vol', 'vols',
|
|
106
|
+
'pp', 'p', 'ch', 'chap', 'app', 'appx', 'dr', 'mr', 'mrs', 'ms', 'prof', 'st', 'rev', 'gen',
|
|
107
|
+
'gov', 'dept', 'univ', 'inc', 'ltd', 'ed', 'eds', 'al', 'jr', 'sr', 'ph', 'th',
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
export function isAbbreviationAt(source, dotIndex) {
|
|
111
|
+
if (typeof source !== 'string' || !Number.isInteger(dotIndex)) return false;
|
|
112
|
+
let start = dotIndex - 1;
|
|
113
|
+
while (start >= 0 && /[A-Za-z.]/.test(source[start])) start -= 1;
|
|
114
|
+
const token = source.slice(start + 1, dotIndex);
|
|
115
|
+
if (!token) return false;
|
|
116
|
+
if (/^[A-Z]$/.test(token)) {
|
|
117
|
+
// A single capital before the period is an initial (A. M. Turing) only when
|
|
118
|
+
// it is followed by another initial; a sentence-final "do X." is a boundary.
|
|
119
|
+
return /^\s*(?:['\u2019\u201D"\u201C»›)\]}]*\s*)*[A-Z]\s*\./.test(source.slice(dotIndex + 1));
|
|
120
|
+
}
|
|
121
|
+
return SENTENCE_ABBREVIATIONS.has(token.toLowerCase());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Closing quotes/brackets that may legally follow a sentence-final period.
|
|
125
|
+
const CLOSING_PUNCTUATION = '"' + "'" + ')\]}' + '\u2019\u201D\u201C\u2018»›';
|
|
126
|
+
|
|
127
|
+
// Given the index of a sentence-final character (.?!), return the index just
|
|
128
|
+
// past any trailing closing quotes/brackets, or -1 when the character is glued
|
|
129
|
+
// to a following word/command (i.e. not a boundary).
|
|
130
|
+
export function sentenceEndIndex(source, punctIndex, end = source.length) {
|
|
131
|
+
let cursor = punctIndex + 1;
|
|
132
|
+
while (cursor < end && CLOSING_PUNCTUATION.includes(source[cursor])) cursor += 1;
|
|
133
|
+
const follower = source[cursor];
|
|
134
|
+
if (cursor < end && follower !== undefined && !/\s/.test(follower)) {
|
|
135
|
+
const visibleCommand = source.slice(cursor, end).match(/^\\(?:emph|textbf|textit|textrm|texttt|underline|mbox)\b/);
|
|
136
|
+
if (!visibleCommand) return -1; // glued to a word or a non-prose command
|
|
137
|
+
}
|
|
138
|
+
return cursor;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sentenceRanges(source, start, end) {
|
|
142
|
+
const ranges = [];
|
|
143
|
+
let sentenceStart = start;
|
|
144
|
+
const suppressedBraces = [];
|
|
145
|
+
for (let cursor = start; cursor < end; cursor += 1) {
|
|
146
|
+
const character = source[cursor];
|
|
147
|
+
if (character === '{' && !isEscaped(source, cursor)) {
|
|
148
|
+
const command = source.slice(Math.max(start, cursor - 120), cursor)
|
|
149
|
+
.match(/\\([a-zA-Z@]+)\*?(?:\[[^\]]*\])*\s*$/)?.[1]?.toLowerCase() || '';
|
|
150
|
+
const suppressed = Boolean(suppressedBraces.at(-1))
|
|
151
|
+
|| /^(?:cite\w*|ref|eqref|pageref|autoref|label|footnote|url)$/.test(command);
|
|
152
|
+
suppressedBraces.push(suppressed);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (character === '}' && !isEscaped(source, cursor)) {
|
|
156
|
+
suppressedBraces.pop();
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!'.?!'.includes(character) || isEscaped(source, cursor) || suppressedBraces.at(-1)) continue;
|
|
160
|
+
const previous = source[cursor - 1];
|
|
161
|
+
const boundary = sentenceEndIndex(source, cursor, end);
|
|
162
|
+
if (boundary === -1) continue; // glued to command, citation, etc.
|
|
163
|
+
// Next non-space character after the punctuation (and any closing quotes).
|
|
164
|
+
let look = boundary;
|
|
165
|
+
while (look < end && /\s/.test(source[look])) look += 1;
|
|
166
|
+
const nextNonSpace = source[look];
|
|
167
|
+
if (/\d/.test(previous || '') && /\d/.test(nextNonSpace || '')) continue; // decimal number
|
|
168
|
+
if (character === '.' && isAbbreviationAt(source, cursor)) continue; // e.g. i.e. cf. A. M.
|
|
169
|
+
if (look < end && /[a-z]/.test(nextNonSpace || '')) continue; // embedded quote: "think?" is ...
|
|
170
|
+
const range = trimRange(source, sentenceStart, boundary);
|
|
171
|
+
if (range.end > range.start) ranges.push(range);
|
|
172
|
+
sentenceStart = boundary;
|
|
173
|
+
cursor = boundary - 1;
|
|
174
|
+
}
|
|
175
|
+
const tail = trimRange(source, sentenceStart, end);
|
|
176
|
+
if (tail.end > tail.start) ranges.push(tail);
|
|
177
|
+
return ranges;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function inferIntent(value, index, count) {
|
|
181
|
+
const text = plainText(value).toLowerCase();
|
|
182
|
+
if (/\?$/.test(text)) return 'Pose a research question.';
|
|
183
|
+
if (/\b(we propose|we present|this paper contributes|our contribution)\b/.test(text)) return 'State the paper contribution.';
|
|
184
|
+
if (/\b(results?|findings?)\b.*\b(show|demonstrate|indicate|suggest)\b/.test(text)) return 'Report an empirical finding.';
|
|
185
|
+
if (/\b(however|nevertheless|in contrast|although)\b/.test(text)) return 'Introduce a contrast or limitation.';
|
|
186
|
+
if (/\b(therefore|thus|consequently|because)\b/.test(text)) return 'Explain reasoning or a consequence.';
|
|
187
|
+
if (index === 0) return 'Establish the paragraph topic.';
|
|
188
|
+
if (index === count - 1 && count > 1) return 'Conclude the point or transition onward.';
|
|
189
|
+
return 'Develop or support the paragraph argument.';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isProseChunk(raw) {
|
|
193
|
+
const trimmed = raw.trim();
|
|
194
|
+
if (!trimmed) return false;
|
|
195
|
+
if (/^\\(?:begin|end)\{(?:equation|align|figure|table|tikzpicture|thebibliography)\}/.test(trimmed)) return false;
|
|
196
|
+
if (/^(?:\\(?:maketitle|bibliography|bibliographystyle|label)\b[^\n]*\s*)+$/.test(trimmed)) return false;
|
|
197
|
+
return plainText(trimmed).length > 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function paragraphRanges(source, start, end) {
|
|
201
|
+
const ranges = [];
|
|
202
|
+
const separator = /\n[ \t]*\n+/g;
|
|
203
|
+
separator.lastIndex = start;
|
|
204
|
+
let cursor = start;
|
|
205
|
+
let match;
|
|
206
|
+
while ((match = separator.exec(source)) && match.index < end) {
|
|
207
|
+
const range = trimRange(source, cursor, match.index);
|
|
208
|
+
if (range.end > range.start && isProseChunk(source.slice(range.start, range.end))) ranges.push(range);
|
|
209
|
+
cursor = match.index + match[0].length;
|
|
210
|
+
}
|
|
211
|
+
const range = trimRange(source, cursor, end);
|
|
212
|
+
if (range.end > range.start && isProseChunk(source.slice(range.start, range.end))) ranges.push(range);
|
|
213
|
+
return ranges;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function previousMatcher(items, keyOf) {
|
|
217
|
+
const map = new Map();
|
|
218
|
+
for (const item of items || []) {
|
|
219
|
+
const key = keyOf(item);
|
|
220
|
+
if (!map.has(key)) map.set(key, []);
|
|
221
|
+
map.get(key).push(item);
|
|
222
|
+
}
|
|
223
|
+
return { map, items: items || [], used: new Set() };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function takePrevious(matcher, key, index) {
|
|
227
|
+
const matches = matcher.map.get(key) || [];
|
|
228
|
+
let exact = matches.shift();
|
|
229
|
+
while (exact && matcher.used.has(exact.id)) exact = matches.shift();
|
|
230
|
+
if (exact) {
|
|
231
|
+
matcher.used.add(exact.id);
|
|
232
|
+
return exact;
|
|
233
|
+
}
|
|
234
|
+
const positional = matcher.items[index];
|
|
235
|
+
if (positional && !matcher.used.has(positional.id)) {
|
|
236
|
+
matcher.used.add(positional.id);
|
|
237
|
+
return positional;
|
|
238
|
+
}
|
|
239
|
+
const unused = matcher.items.find((item) => !matcher.used.has(item.id));
|
|
240
|
+
if (unused) matcher.used.add(unused.id);
|
|
241
|
+
return unused || null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function buildParagraphs(source, section, previousSection) {
|
|
245
|
+
const previousParagraphs = previousSection?.children || [];
|
|
246
|
+
const paragraphMatcher = previousMatcher(previousParagraphs, (item) => fingerprint(item.text || ''));
|
|
247
|
+
return paragraphRanges(source, section.contentStart, section.contentEnd).map((range, paragraphIndex) => {
|
|
248
|
+
const raw = source.slice(range.start, range.end);
|
|
249
|
+
const previous = takePrevious(paragraphMatcher, fingerprint(raw), paragraphIndex);
|
|
250
|
+
const paragraphId = previous?.id || id('paragraph');
|
|
251
|
+
const sentenceSourceRanges = sentenceRanges(source, range.start, range.end);
|
|
252
|
+
const previousSentences = previous?.children || [];
|
|
253
|
+
const sentenceMatcher = previousMatcher(previousSentences, (item) => fingerprint(item.text || ''));
|
|
254
|
+
const children = sentenceSourceRanges.map((sentenceRange, sentenceIndex) => {
|
|
255
|
+
const sentenceRaw = source.slice(sentenceRange.start, sentenceRange.end);
|
|
256
|
+
const old = takePrevious(sentenceMatcher, fingerprint(sentenceRaw), sentenceIndex);
|
|
257
|
+
return {
|
|
258
|
+
id: old?.id || id('sentence'), type: 'sentence', parentId: paragraphId, order: sentenceIndex,
|
|
259
|
+
text: sentenceRaw, prompt: old?.prompt || '', summary: old?.summary || '',
|
|
260
|
+
intent: old?.intent || inferIntent(sentenceRaw, sentenceIndex, sentenceSourceRanges.length),
|
|
261
|
+
sourceRange: { ...sentenceRange, contentStart: sentenceRange.start, contentEnd: sentenceRange.end },
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
const readable = plainText(raw);
|
|
265
|
+
return {
|
|
266
|
+
id: paragraphId, type: 'paragraph', parentId: section.id, order: paragraphIndex,
|
|
267
|
+
text: raw, prompt: previous?.prompt || '',
|
|
268
|
+
summary: previous?.summary || plainText(children[0]?.text || readable).slice(0, 180),
|
|
269
|
+
sourceRange: { ...range, contentStart: range.start, contentEnd: range.end }, children,
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function parseLatexDocument(source, previousDocument = {}) {
|
|
275
|
+
if (typeof source !== 'string') throw new TypeError('LaTeX source must be a string');
|
|
276
|
+
const beginDocument = source.search(/\\begin\s*\{document\}/);
|
|
277
|
+
const endDocument = source.search(/\\end\s*\{document\}/);
|
|
278
|
+
const bodyStart = beginDocument === -1 ? 0 : beginDocument + source.slice(beginDocument).match(/^\\begin\s*\{document\}/)[0].length;
|
|
279
|
+
const bodyEnd = endDocument === -1 ? source.length : endDocument;
|
|
280
|
+
const title = findCommandValue(source, 'title', 0, bodyStart)?.value.trim() || previousDocument.title || '';
|
|
281
|
+
const headings = findHeadings(source, bodyStart, bodyEnd);
|
|
282
|
+
const descriptors = [];
|
|
283
|
+
|
|
284
|
+
const abstractStartMatch = /\\begin\s*\{abstract\}/g;
|
|
285
|
+
abstractStartMatch.lastIndex = bodyStart;
|
|
286
|
+
const abstractMatch = abstractStartMatch.exec(source);
|
|
287
|
+
if (abstractMatch && abstractMatch.index < bodyEnd) {
|
|
288
|
+
const closeRegex = /\\end\s*\{abstract\}/g;
|
|
289
|
+
closeRegex.lastIndex = abstractMatch.index + abstractMatch[0].length;
|
|
290
|
+
const close = closeRegex.exec(source);
|
|
291
|
+
if (close && close.index < bodyEnd) {
|
|
292
|
+
descriptors.push({
|
|
293
|
+
command: 'abstract', level: 1, title: 'Abstract', start: abstractMatch.index,
|
|
294
|
+
headingEnd: abstractMatch.index + abstractMatch[0].length,
|
|
295
|
+
contentStart: abstractMatch.index + abstractMatch[0].length, contentEnd: close.index,
|
|
296
|
+
end: close.index + close[0].length,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
headings.forEach((heading, index) => {
|
|
302
|
+
const next = headings[index + 1];
|
|
303
|
+
descriptors.push({
|
|
304
|
+
...heading, contentStart: heading.headingEnd, contentEnd: next?.start || bodyEnd,
|
|
305
|
+
end: next?.start || bodyEnd,
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
descriptors.sort((a, b) => a.start - b.start);
|
|
309
|
+
|
|
310
|
+
const previousSections = previousDocument.sections || [];
|
|
311
|
+
const sectionMatcher = previousMatcher(previousSections, (item) => `${item.level || 1}:${normalize(item.title || item.text || '')}`);
|
|
312
|
+
const sections = descriptors.map((descriptor, sectionIndex) => {
|
|
313
|
+
const key = `${descriptor.level}:${normalize(descriptor.title)}`;
|
|
314
|
+
const previous = takePrevious(sectionMatcher, key, sectionIndex);
|
|
315
|
+
const section = {
|
|
316
|
+
id: previous?.id || id('section'), type: 'section', parentId: previousDocument.id || '', order: sectionIndex,
|
|
317
|
+
level: descriptor.level, command: descriptor.command, title: descriptor.title, text: descriptor.title,
|
|
318
|
+
prompt: previous?.prompt || '', summary: previous?.summary || '',
|
|
319
|
+
sourceRange: {
|
|
320
|
+
start: descriptor.start, end: descriptor.end,
|
|
321
|
+
contentStart: descriptor.contentStart, contentEnd: descriptor.contentEnd,
|
|
322
|
+
},
|
|
323
|
+
children: [],
|
|
324
|
+
};
|
|
325
|
+
section.children = buildParagraphs(source, { ...descriptor, id: section.id }, previous);
|
|
326
|
+
if (!section.summary) section.summary = section.children[0]?.summary || '';
|
|
327
|
+
return section;
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
return { title, sections, sourceLength: source.length };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function findStructureNode(document, nodeId) {
|
|
334
|
+
if (document.id === nodeId) return document;
|
|
335
|
+
const visit = (nodes) => {
|
|
336
|
+
for (const node of nodes || []) {
|
|
337
|
+
if (node.id === nodeId) return node;
|
|
338
|
+
const found = visit(node.children);
|
|
339
|
+
if (found) return found;
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
};
|
|
343
|
+
return visit(document.sections);
|
|
344
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { resolve as pathResolve, dirname, basename } from 'path';
|
|
3
|
+
|
|
4
|
+
const ENGINE_ORDER = ['tectonic', 'pdflatex', 'xelatex', 'lualatex'];
|
|
5
|
+
const COMPILE_TIMEOUT_MS = 30000;
|
|
6
|
+
const FIND_COMMAND = process.platform === 'win32' ? 'where' : 'which';
|
|
7
|
+
|
|
8
|
+
export async function detectEngines() {
|
|
9
|
+
const available = [];
|
|
10
|
+
for (const engine of ENGINE_ORDER) {
|
|
11
|
+
try {
|
|
12
|
+
await new Promise((res, rej) => {
|
|
13
|
+
execFile(FIND_COMMAND, [engine], { timeout: 5000, shell: false }, (err) => {
|
|
14
|
+
if (err) rej(err);
|
|
15
|
+
else res();
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
available.push(engine);
|
|
19
|
+
} catch {}
|
|
20
|
+
}
|
|
21
|
+
return available;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function compile(texPath, workspaceRoot) {
|
|
25
|
+
const engines = await detectEngines();
|
|
26
|
+
if (engines.length === 0) {
|
|
27
|
+
return { ok: false, error: 'No LaTeX engine found. Install pdflatex, xelatex, lualatex, or tectonic.', engine: null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const engine = engines[0];
|
|
31
|
+
const fileDir = dirname(texPath);
|
|
32
|
+
const fileBase = basename(texPath, '.tex');
|
|
33
|
+
|
|
34
|
+
let args;
|
|
35
|
+
if (engine === 'tectonic') {
|
|
36
|
+
args = [texPath, '--outdir', fileDir];
|
|
37
|
+
} else {
|
|
38
|
+
args = ['-no-shell-escape', '-interaction=nonstopmode', '-halt-on-error', '-output-directory', fileDir, fileBase];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return new Promise((done) => {
|
|
42
|
+
execFile(engine, args, {
|
|
43
|
+
cwd: fileDir,
|
|
44
|
+
timeout: COMPILE_TIMEOUT_MS,
|
|
45
|
+
shell: false,
|
|
46
|
+
env: {
|
|
47
|
+
...process.env,
|
|
48
|
+
openin_any: 'p',
|
|
49
|
+
openout_any: 'p',
|
|
50
|
+
shell_escape: 'f',
|
|
51
|
+
},
|
|
52
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
53
|
+
killSignal: 'SIGKILL',
|
|
54
|
+
}, (err, stdout, stderr) => {
|
|
55
|
+
if (err) {
|
|
56
|
+
if (err.killed) {
|
|
57
|
+
return done({ ok: false, error: 'Compilation timed out (30s)', engine });
|
|
58
|
+
}
|
|
59
|
+
const combined = (stderr || '') + (stdout || '');
|
|
60
|
+
const errorMatch = combined.match(/^!.*$/m);
|
|
61
|
+
return done({ ok: false, error: errorMatch?.[0] || stderr || err.message, engine, log: combined });
|
|
62
|
+
}
|
|
63
|
+
const pdfPath = pathResolve(fileDir, fileBase + '.pdf');
|
|
64
|
+
done({ ok: true, pdf: pdfPath, engine, output: stdout });
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const DEFAULT_LIMITS = { corpora: 3, patterns: 3, vocabulary: 12 };
|
|
2
|
+
const STOP_WORDS = new Set([
|
|
3
|
+
'about', 'after', 'again', 'against', 'among', 'because', 'before', 'being', 'between', 'could',
|
|
4
|
+
'during', 'first', 'from', 'have', 'into', 'more', 'other', 'paper', 'results', 'should', 'their',
|
|
5
|
+
'there', 'these', 'they', 'this', 'through', 'using', 'were', 'which', 'with', 'would',
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
function words(value) {
|
|
9
|
+
return [...new Set(String(value || '').toLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) || [])];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function textFor(type, item) {
|
|
13
|
+
if (type === 'corpus') return [item.name, item.description, item.content, item.source, ...(item.tags || [])].join(' ');
|
|
14
|
+
if (type === 'pattern') return [item.name, item.description, item.template, item.source, ...(item.tags || []), ...(item.sectionTypes || [])].join(' ');
|
|
15
|
+
return [item.term, item.preferred, item.definition, item.source, ...(item.tags || []), ...(item.alternatives || []), ...(item.examples || [])].join(' ');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function scoreItem(type, item, queryTokens, tags) {
|
|
19
|
+
const haystack = textFor(type, item).toLowerCase();
|
|
20
|
+
let score = 0;
|
|
21
|
+
queryTokens.forEach((token) => {
|
|
22
|
+
if (haystack.includes(token)) score += haystack.includes(` ${token} `) ? 4 : 2;
|
|
23
|
+
});
|
|
24
|
+
tags.forEach((tag) => {
|
|
25
|
+
if ((item.tags || []).some((candidate) => candidate.toLowerCase() === tag)) score += 6;
|
|
26
|
+
});
|
|
27
|
+
return score;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ranked(type, items, { query = '', tags = [], limit = 10, sectionType = '' } = {}) {
|
|
31
|
+
const queryTokens = words(query);
|
|
32
|
+
const normalizedTags = tags.map((tag) => String(tag).toLowerCase());
|
|
33
|
+
return items
|
|
34
|
+
.filter((item) => type !== 'pattern' || !sectionType || item.sectionTypes.length === 0
|
|
35
|
+
|| item.sectionTypes.some((candidate) => candidate.toLowerCase() === sectionType.toLowerCase()))
|
|
36
|
+
.map((item, index) => ({ item, index, score: scoreItem(type, item, queryTokens, normalizedTags) }))
|
|
37
|
+
.filter((entry) => queryTokens.length === 0 && normalizedTags.length === 0 || entry.score > 0)
|
|
38
|
+
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
39
|
+
.slice(0, Math.max(0, Math.min(Number(limit) || 10, 100)))
|
|
40
|
+
.map(({ item, score }) => ({ ...item, relevance: score }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mergedVocabulary(libraries) {
|
|
44
|
+
const merged = new Map();
|
|
45
|
+
for (const item of libraries.vocabulary.global) merged.set(item.term.trim().toLowerCase(), { ...item, scope: 'global' });
|
|
46
|
+
for (const item of libraries.vocabulary.session) merged.set(item.term.trim().toLowerCase(), { ...item, scope: 'session' });
|
|
47
|
+
return [...merged.values()];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function searchLibraries(libraries, options = {}) {
|
|
51
|
+
return {
|
|
52
|
+
corpora: ranked('corpus', libraries.corpora, { ...options, limit: options.limits?.corpora ?? options.limit ?? 10 }),
|
|
53
|
+
sentencePatterns: ranked('pattern', libraries.sentencePatterns, { ...options, limit: options.limits?.patterns ?? options.limit ?? 10 }),
|
|
54
|
+
vocabulary: ranked('vocabulary', mergedVocabulary(libraries), { ...options, limit: options.limits?.vocabulary ?? options.limit ?? 20 }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function allResources(libraries) {
|
|
59
|
+
return [
|
|
60
|
+
...libraries.corpora.map((item) => ({ type: 'corpus', item })),
|
|
61
|
+
...libraries.sentencePatterns.map((item) => ({ type: 'pattern', item })),
|
|
62
|
+
...libraries.vocabulary.global.map((item) => ({ type: 'vocabulary', scope: 'global', item })),
|
|
63
|
+
...libraries.vocabulary.session.map((item) => ({ type: 'vocabulary', scope: 'session', item })),
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function descriptor(type, item, scope) {
|
|
68
|
+
return { id: item.id, type, scope: scope || null, name: item.name || item.term };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildLibraryContext(libraries, { query = '', tags = [], sectionType = '', resourceIds = [] } = {}) {
|
|
72
|
+
const explicit = new Set(Array.isArray(resourceIds) ? resourceIds : []);
|
|
73
|
+
let selected;
|
|
74
|
+
if (explicit.size > 0) {
|
|
75
|
+
selected = allResources(libraries).filter(({ item }) => explicit.has(item.id));
|
|
76
|
+
} else {
|
|
77
|
+
const found = searchLibraries(libraries, { query, tags, sectionType, limits: DEFAULT_LIMITS });
|
|
78
|
+
selected = [
|
|
79
|
+
...found.corpora.map((item) => ({ type: 'corpus', item })),
|
|
80
|
+
...found.sentencePatterns.map((item) => ({ type: 'pattern', item })),
|
|
81
|
+
...found.vocabulary.map((item) => ({ type: 'vocabulary', scope: item.scope, item })),
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const blocks = selected.map(({ type, scope, item }) => {
|
|
86
|
+
if (type === 'corpus') {
|
|
87
|
+
return `[CORPUS ${item.id}] ${item.name}\nSource: ${item.source || 'user library'}\n${item.content}`;
|
|
88
|
+
}
|
|
89
|
+
if (type === 'pattern') {
|
|
90
|
+
const slots = item.slots.map((slot) => `${slot.name}${slot.required ? '*' : ''}: ${slot.description}`).join('; ');
|
|
91
|
+
return `[SENTENCE_PATTERN ${item.id}] ${item.name}\nTemplate: ${item.template}\nSlots: ${slots || 'none'}`;
|
|
92
|
+
}
|
|
93
|
+
return `[VOCABULARY ${item.id} scope=${scope}] ${item.term}${item.preferred ? ` -> prefer: ${item.preferred}` : ''}`
|
|
94
|
+
+ `${item.definition ? `\nMeaning: ${item.definition}` : ''}`;
|
|
95
|
+
});
|
|
96
|
+
const resources = selected.map(({ type, scope, item }) => descriptor(type, item, scope));
|
|
97
|
+
return {
|
|
98
|
+
prompt: blocks.length ? `Writing library resources:\n${blocks.join('\n\n')}\n\nUse only resources that improve the text. Report the IDs you actually use in usedResourceIds.` : '',
|
|
99
|
+
resources,
|
|
100
|
+
resourceIds: resources.map((item) => item.id),
|
|
101
|
+
mode: explicit.size > 0 ? 'selected' : 'automatic',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function renderSentencePattern(pattern, values = {}) {
|
|
106
|
+
if (!pattern || typeof pattern.template !== 'string') throw new Error('Pattern is required');
|
|
107
|
+
const missing = (pattern.slots || [])
|
|
108
|
+
.filter((slot) => slot.required && (typeof values[slot.name] !== 'string' || !values[slot.name].trim()))
|
|
109
|
+
.map((slot) => slot.name);
|
|
110
|
+
if (missing.length) {
|
|
111
|
+
const error = new Error(`Missing required pattern slots: ${missing.join(', ')}`);
|
|
112
|
+
error.code = 'MISSING_PATTERN_SLOTS';
|
|
113
|
+
error.status = 400;
|
|
114
|
+
error.details = missing;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
const rendered = pattern.template.replace(/\{([a-zA-Z][\w-]*)\}/g, (match, name) => {
|
|
118
|
+
const value = values[name];
|
|
119
|
+
return typeof value === 'string' && value.trim() ? value.trim() : match;
|
|
120
|
+
});
|
|
121
|
+
return { rendered, patternId: pattern.id, values };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function cleanLatex(content) {
|
|
125
|
+
return content
|
|
126
|
+
.replace(/(?<!\\)%.*$/gm, ' ')
|
|
127
|
+
.replace(/\\(?:begin|end)\{[^}]+\}/g, ' ')
|
|
128
|
+
.replace(/\\[a-zA-Z@]+\*?(?:\[[^\]]*\])?(?:\{([^{}]*)\})?/g, '$1')
|
|
129
|
+
.replace(/[{}$]/g, ' ')
|
|
130
|
+
.replace(/\s+/g, ' ')
|
|
131
|
+
.trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function extractLibraryCandidates(content, source = 'current document') {
|
|
135
|
+
const clean = cleanLatex(content);
|
|
136
|
+
const sentences = clean.match(/[^.!?]+[.!?]+/g) || [];
|
|
137
|
+
const patterns = sentences
|
|
138
|
+
.map((sentence) => sentence.trim())
|
|
139
|
+
.filter((sentence) => sentence.length >= 35 && sentence.length <= 300)
|
|
140
|
+
.slice(0, 12)
|
|
141
|
+
.map((template, index) => ({
|
|
142
|
+
kind: 'sentence-patterns',
|
|
143
|
+
value: {
|
|
144
|
+
name: `Extracted expression ${index + 1}`, template, description: 'Candidate extracted from the current paper.',
|
|
145
|
+
tags: ['extracted'], sectionTypes: [], slots: [], source,
|
|
146
|
+
},
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
const frequencies = new Map();
|
|
150
|
+
for (const word of clean.toLowerCase().match(/[a-z][a-z-]{6,}/g) || []) {
|
|
151
|
+
if (!STOP_WORDS.has(word)) frequencies.set(word, (frequencies.get(word) || 0) + 1);
|
|
152
|
+
}
|
|
153
|
+
const vocabulary = [...frequencies.entries()]
|
|
154
|
+
.filter(([, count]) => count >= 2)
|
|
155
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
156
|
+
.slice(0, 12)
|
|
157
|
+
.map(([term, count]) => ({
|
|
158
|
+
kind: 'vocabulary', scope: 'session',
|
|
159
|
+
value: {
|
|
160
|
+
term, preferred: term, definition: `Appears ${count} times in the current paper.`, source,
|
|
161
|
+
alternatives: [], examples: [], tags: ['extracted'],
|
|
162
|
+
},
|
|
163
|
+
}));
|
|
164
|
+
return { patterns, vocabulary };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function composeMockParagraph(libraries, context, instruction = '') {
|
|
168
|
+
const selected = new Set(context.resourceIds || []);
|
|
169
|
+
const corpora = libraries.corpora.filter((item) => selected.has(item.id));
|
|
170
|
+
const patterns = libraries.sentencePatterns.filter((item) => selected.has(item.id));
|
|
171
|
+
const vocabulary = mergedVocabulary(libraries).filter((item) => selected.has(item.id));
|
|
172
|
+
const pieces = [];
|
|
173
|
+
const usedResourceIds = [];
|
|
174
|
+
if (corpora[0]) {
|
|
175
|
+
pieces.push(corpora[0].content.trim());
|
|
176
|
+
usedResourceIds.push(corpora[0].id);
|
|
177
|
+
}
|
|
178
|
+
if (patterns[0]) {
|
|
179
|
+
const rendered = patterns[0].template.replace(/\{([a-zA-Z][\w-]*)\}/g, (_match, name) => `[${name}]`);
|
|
180
|
+
pieces.push(rendered);
|
|
181
|
+
usedResourceIds.push(patterns[0].id);
|
|
182
|
+
}
|
|
183
|
+
if (vocabulary.length) {
|
|
184
|
+
const terms = vocabulary.slice(0, 4).map((item) => item.preferred || item.term);
|
|
185
|
+
pieces.push(`Use the preferred terminology ${terms.join(', ')} while developing this argument.`);
|
|
186
|
+
usedResourceIds.push(...vocabulary.slice(0, 4).map((item) => item.id));
|
|
187
|
+
}
|
|
188
|
+
if (!pieces.length) {
|
|
189
|
+
const topic = instruction.trim().replace(/[.!?]+$/, '');
|
|
190
|
+
pieces.push(topic ? `This paragraph develops the following point: ${topic}.` : 'Develop the central claim with evidence and appropriate qualifications.');
|
|
191
|
+
}
|
|
192
|
+
return { draft: pieces.join(' '), usedResourceIds: [...new Set(usedResourceIds)] };
|
|
193
|
+
}
|