@ncukondo/search-hub 0.10.0 → 0.12.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.
Files changed (36) hide show
  1. package/dist/cli/commands/merge.d.ts +82 -0
  2. package/dist/cli/commands/merge.d.ts.map +1 -0
  3. package/dist/cli/commands/merge.js +221 -0
  4. package/dist/cli/commands/merge.js.map +1 -0
  5. package/dist/cli/commands/resume.d.ts.map +1 -1
  6. package/dist/cli/commands/resume.js +7 -0
  7. package/dist/cli/commands/resume.js.map +1 -1
  8. package/dist/cli/commands/review/extract.d.ts +3 -1
  9. package/dist/cli/commands/review/extract.d.ts.map +1 -1
  10. package/dist/cli/commands/review/extract.js +75 -45
  11. package/dist/cli/commands/review/extract.js.map +1 -1
  12. package/dist/cli/commands/review/mark.d.ts.map +1 -1
  13. package/dist/cli/commands/review/mark.js +69 -22
  14. package/dist/cli/commands/review/mark.js.map +1 -1
  15. package/dist/cli/commands/review/merge.d.ts.map +1 -1
  16. package/dist/cli/commands/review/merge.js +8 -2
  17. package/dist/cli/commands/review/merge.js.map +1 -1
  18. package/dist/cli/commands/review/types.d.ts +4 -0
  19. package/dist/cli/commands/review/types.d.ts.map +1 -1
  20. package/dist/cli/commands/review/types.js +10 -3
  21. package/dist/cli/commands/review/types.js.map +1 -1
  22. package/dist/cli/commands/status.js +1 -1
  23. package/dist/cli/commands/status.js.map +1 -1
  24. package/dist/cli/index.d.ts.map +1 -1
  25. package/dist/cli/index.js +143 -4
  26. package/dist/cli/index.js.map +1 -1
  27. package/dist/cli/suggestions/rules.d.ts.map +1 -1
  28. package/dist/cli/suggestions/rules.js +20 -2
  29. package/dist/cli/suggestions/rules.js.map +1 -1
  30. package/dist/cli/suggestions/types.d.ts +6 -0
  31. package/dist/cli/suggestions/types.d.ts.map +1 -1
  32. package/dist/session/types.d.ts +14 -1
  33. package/dist/session/types.d.ts.map +1 -1
  34. package/dist/session/types.js +7 -0
  35. package/dist/session/types.js.map +1 -0
  36. package/package.json +1 -1
@@ -0,0 +1,82 @@
1
+ import { Article, ProviderName } from '../../providers/base/types.js';
2
+ import { SessionFile, SessionSource } from '../../session/types.js';
3
+ /**
4
+ * Result of merging articles from multiple sessions.
5
+ */
6
+ export interface MergeResult {
7
+ /** All unique articles after deduplication */
8
+ articles: Article[];
9
+ /** Articles grouped by provider */
10
+ byProvider: Map<ProviderName, Article[]>;
11
+ /** Total article count before deduplication */
12
+ totalBefore: number;
13
+ /** Total unique article count after deduplication */
14
+ totalAfter: number;
15
+ /** Number of duplicates removed */
16
+ duplicatesRemoved: number;
17
+ /** Per-session article counts (before dedup) */
18
+ perSession: Map<string, number>;
19
+ }
20
+ /**
21
+ * Options for creating a merged session.
22
+ */
23
+ export interface CreateMergedSessionOptions {
24
+ name: string;
25
+ sources: SessionSource[];
26
+ byProvider: Map<ProviderName, Article[]>;
27
+ totalRetrieved: number;
28
+ sessionsDir: string;
29
+ sourceSessionIds: string[];
30
+ }
31
+ /**
32
+ * Validation result for merge sources.
33
+ */
34
+ export interface MergeValidationResult {
35
+ valid: boolean;
36
+ error?: string;
37
+ expandedCommand?: string;
38
+ }
39
+ /**
40
+ * Output data for formatting merge results.
41
+ */
42
+ export interface MergeOutputData {
43
+ sessionId: string;
44
+ totalBefore: number;
45
+ totalAfter: number;
46
+ duplicatesRemoved: number;
47
+ sources: Array<{
48
+ id: string;
49
+ name: string;
50
+ count: number;
51
+ }>;
52
+ byProvider: Map<string, number>;
53
+ }
54
+ /**
55
+ * Merge articles from multiple sessions with identifier-based deduplication.
56
+ *
57
+ * When duplicates are found (same DOI, PMID, etc.), the article with
58
+ * richer metadata is kept.
59
+ */
60
+ export declare function mergeArticles(sessionArticles: Map<string, Article[]>): MergeResult;
61
+ /**
62
+ * Copy source session provenance files to sources/ subdirectory.
63
+ * Copies session.yaml, query_common.yaml, and query text files.
64
+ */
65
+ export declare function copySourceProvenance(sourceSessionId: string, sessionsDir: string, mergedSessionDir: string): Promise<void>;
66
+ /**
67
+ * Validate that all source sessions are valid for merging.
68
+ */
69
+ export declare function validateMergeSources(sessions: Map<string, SessionFile>): MergeValidationResult;
70
+ /**
71
+ * Create a merged session on disk.
72
+ */
73
+ export declare function createMergedSession(options: CreateMergedSessionOptions): Promise<SessionFile>;
74
+ /**
75
+ * Format merge result as human-readable text.
76
+ */
77
+ export declare function formatMergeOutput(data: MergeOutputData): string;
78
+ /**
79
+ * Format merge result as JSON.
80
+ */
81
+ export declare function formatMergeJson(data: MergeOutputData): string;
82
+ //# sourceMappingURL=merge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"merge.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/merge.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAC3E,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAkB,MAAM,wBAAwB,CAAC;AAMzF;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,8CAA8C;IAC9C,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,mCAAmC;IACnC,UAAU,EAAE,GAAG,CAAC,YAAY,EAAE,OAAO,EAAE,CAAC,CAAC;IACzC,+CAA+C;IAC/C,WAAW,EAAE,MAAM,CAAC;IACpB,qDAAqD;IACrD,UAAU,EAAE,MAAM,CAAC;IACnB,mCAAmC;IACnC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gDAAgD;IAChD,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED;;GAEG;AACH,MAAM,WAAW,0BAA0B;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,UAAU,EAAE,GAAG,CAAC,YAAY,EAAE,OAAO,EAAE,CAAC,CAAC;IACzC,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,EAAE,CAAC;CAC5B;AAED;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,OAAO,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC5D,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAsBD;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,eAAe,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,GACtC,WAAW,CAgEb;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,eAAe,EAAE,MAAM,EACvB,WAAW,EAAE,MAAM,EACnB,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,IAAI,CAAC,CAqBf;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GACjC,qBAAqB,CA+BvB;AAYD;;GAEG;AACH,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,0BAA0B,GAClC,OAAO,CAAC,WAAW,CAAC,CAuEtB;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,eAAe,GAAG,MAAM,CAqB/D;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,eAAe,GAAG,MAAM,CAS7D"}
@@ -0,0 +1,221 @@
1
+ import { mkdir, writeFile, copyFile, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { stringify } from "yaml";
5
+ import { isMergedSession } from "../../session/types.js";
6
+ import { sanitizeName } from "../../session/manager.js";
7
+ import { convertResultsToYaml } from "../../session/results-io.js";
8
+ import { getArticleKeys } from "./session-utils.js";
9
+ function countMetadataFields(article) {
10
+ let count = 0;
11
+ if (article.doi) count++;
12
+ if (article.pmid) count++;
13
+ if (article.arxivId) count++;
14
+ if (article.scopusId) count++;
15
+ if (article.ericId) count++;
16
+ if (article.abstract) count++;
17
+ if (article.publicationDate) count++;
18
+ if (article.journal) count++;
19
+ if (article.volume) count++;
20
+ if (article.issue) count++;
21
+ if (article.pages) count++;
22
+ if (article.authors.length > 0) count++;
23
+ return count;
24
+ }
25
+ function mergeArticles(sessionArticles) {
26
+ const keyToIndex = /* @__PURE__ */ new Map();
27
+ const unique = [];
28
+ let totalBefore = 0;
29
+ let duplicatesRemoved = 0;
30
+ const perSession = /* @__PURE__ */ new Map();
31
+ for (const [sessionId, articles] of sessionArticles) {
32
+ perSession.set(sessionId, articles.length);
33
+ totalBefore += articles.length;
34
+ for (const article of articles) {
35
+ const keys = getArticleKeys(article);
36
+ if (keys.length === 0) {
37
+ unique.push(article);
38
+ continue;
39
+ }
40
+ let existingIndex;
41
+ for (const key of keys) {
42
+ const idx = keyToIndex.get(key);
43
+ if (idx !== void 0) {
44
+ existingIndex = idx;
45
+ break;
46
+ }
47
+ }
48
+ if (existingIndex !== void 0) {
49
+ const existing = unique[existingIndex];
50
+ if (countMetadataFields(article) > countMetadataFields(existing)) {
51
+ unique[existingIndex] = article;
52
+ const newKeys = getArticleKeys(article);
53
+ for (const key of newKeys) {
54
+ keyToIndex.set(key, existingIndex);
55
+ }
56
+ }
57
+ duplicatesRemoved++;
58
+ } else {
59
+ const index = unique.length;
60
+ unique.push(article);
61
+ for (const key of keys) {
62
+ keyToIndex.set(key, index);
63
+ }
64
+ }
65
+ }
66
+ }
67
+ const byProvider = /* @__PURE__ */ new Map();
68
+ for (const article of unique) {
69
+ const existing = byProvider.get(article.source) ?? [];
70
+ existing.push(article);
71
+ byProvider.set(article.source, existing);
72
+ }
73
+ return {
74
+ articles: unique,
75
+ byProvider,
76
+ totalBefore,
77
+ totalAfter: unique.length,
78
+ duplicatesRemoved,
79
+ perSession
80
+ };
81
+ }
82
+ async function copySourceProvenance(sourceSessionId, sessionsDir, mergedSessionDir) {
83
+ const sourceDir = join(sessionsDir, sourceSessionId);
84
+ const targetDir = join(mergedSessionDir, "sources", sourceSessionId);
85
+ await mkdir(targetDir, { recursive: true });
86
+ for (const file of ["session.yaml", "query_common.yaml"]) {
87
+ try {
88
+ await copyFile(join(sourceDir, file), join(targetDir, file));
89
+ } catch {
90
+ }
91
+ }
92
+ const entries = await readdir(sourceDir);
93
+ for (const entry of entries) {
94
+ if (entry.startsWith("query_") && entry.endsWith(".txt")) {
95
+ await copyFile(join(sourceDir, entry), join(targetDir, entry));
96
+ }
97
+ }
98
+ }
99
+ function validateMergeSources(sessions) {
100
+ for (const [sessionId, session] of sessions) {
101
+ if (isMergedSession(session)) {
102
+ const originalSources = session.sources?.map((s) => s.id) ?? [];
103
+ const otherIds = [...sessions.keys()].filter((id) => id !== sessionId);
104
+ const allSources = [.../* @__PURE__ */ new Set([...originalSources, ...otherIds])];
105
+ const expandedCommand = `search-hub merge ${allSources.join(" ")}`;
106
+ return {
107
+ valid: false,
108
+ error: `Session '${sessionId}' is a merged session (sources: ${originalSources.join(", ")}).
109
+ Merge the original sources directly:
110
+ ${expandedCommand}`,
111
+ expandedCommand
112
+ };
113
+ }
114
+ }
115
+ for (const [sessionId, session] of sessions) {
116
+ if (session.summary.status !== "completed") {
117
+ return {
118
+ valid: false,
119
+ error: `Session '${sessionId}' is not completed (status: ${session.summary.status}). Only completed sessions can be merged.`
120
+ };
121
+ }
122
+ }
123
+ return { valid: true };
124
+ }
125
+ function generateMergedSessionId(name, sourceIds) {
126
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10).replace(/-/g, "");
127
+ const sanitized = sanitizeName(name);
128
+ const hash = createHash("sha256").update(sourceIds.join(",")).digest("hex").slice(0, 6);
129
+ return `${date}_${sanitized}_${hash}`;
130
+ }
131
+ async function createMergedSession(options) {
132
+ const { name, sources, byProvider, totalRetrieved, sessionsDir, sourceSessionIds } = options;
133
+ const id = generateMergedSessionId(name, sourceSessionIds);
134
+ const sessionDir = join(sessionsDir, id);
135
+ const now = (/* @__PURE__ */ new Date()).toISOString();
136
+ await mkdir(sessionDir, { recursive: true });
137
+ const databases = {};
138
+ for (const [provider, articles] of byProvider) {
139
+ const jsonlFilename = `${provider}_results.jsonl`;
140
+ const yamlFilename = `${provider}_results.yaml`;
141
+ const jsonlPath = join(sessionDir, jsonlFilename);
142
+ const jsonlContent = articles.map((a) => JSON.stringify(a)).join("\n") + "\n";
143
+ await writeFile(jsonlPath, jsonlContent, "utf-8");
144
+ const yamlPath = join(sessionDir, yamlFilename);
145
+ await convertResultsToYaml(jsonlPath, yamlPath, {
146
+ provider,
147
+ queryName: name
148
+ });
149
+ databases[provider] = {
150
+ status: "completed",
151
+ retrievedCount: articles.length,
152
+ files: {
153
+ query: "",
154
+ results: jsonlFilename,
155
+ resultsYaml: yamlFilename
156
+ }
157
+ };
158
+ }
159
+ const sessionFile = {
160
+ version: 1,
161
+ id,
162
+ name,
163
+ type: "merge",
164
+ createdAt: now,
165
+ updatedAt: now,
166
+ sources,
167
+ databases,
168
+ summary: {
169
+ totalHits: 0,
170
+ totalRetrieved,
171
+ status: "completed"
172
+ }
173
+ };
174
+ await writeFile(
175
+ join(sessionDir, "session.yaml"),
176
+ stringify(sessionFile),
177
+ "utf-8"
178
+ );
179
+ for (const sourceId of sourceSessionIds) {
180
+ await copySourceProvenance(sourceId, sessionsDir, sessionDir);
181
+ }
182
+ return sessionFile;
183
+ }
184
+ function formatMergeOutput(data) {
185
+ const lines = [];
186
+ lines.push(`Merged session: ${data.sessionId}`);
187
+ lines.push("");
188
+ lines.push("Sources:");
189
+ for (const source of data.sources) {
190
+ lines.push(` ${source.id} (${source.name}): ${source.count} articles`);
191
+ }
192
+ lines.push("");
193
+ lines.push(`Total articles: ${data.totalBefore} → ${data.totalAfter} unique (${data.duplicatesRemoved} duplicates removed)`);
194
+ if (data.byProvider.size > 0) {
195
+ lines.push("");
196
+ lines.push("By database:");
197
+ for (const [provider, count] of data.byProvider) {
198
+ lines.push(` ${provider}: ${count} articles`);
199
+ }
200
+ }
201
+ return lines.join("\n");
202
+ }
203
+ function formatMergeJson(data) {
204
+ return JSON.stringify({
205
+ sessionId: data.sessionId,
206
+ totalBefore: data.totalBefore,
207
+ totalAfter: data.totalAfter,
208
+ duplicatesRemoved: data.duplicatesRemoved,
209
+ sources: data.sources,
210
+ byProvider: Object.fromEntries(data.byProvider)
211
+ }, null, 2);
212
+ }
213
+ export {
214
+ copySourceProvenance,
215
+ createMergedSession,
216
+ formatMergeJson,
217
+ formatMergeOutput,
218
+ mergeArticles,
219
+ validateMergeSources
220
+ };
221
+ //# sourceMappingURL=merge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"merge.js","sources":["../../../src/cli/commands/merge.ts"],"sourcesContent":["import { mkdir, writeFile, readFile, readdir, copyFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { createHash } from 'node:crypto';\nimport { stringify as stringifyYaml } from 'yaml';\nimport type { Article, ProviderName } from '../../providers/base/types.js';\nimport type { SessionFile, SessionSource, DatabaseStatus } from '../../session/types.js';\nimport { isMergedSession } from '../../session/types.js';\nimport { sanitizeName } from '../../session/manager.js';\nimport { convertResultsToYaml } from '../../session/results-io.js';\nimport { getArticleKeys } from './session-utils.js';\n\n/**\n * Result of merging articles from multiple sessions.\n */\nexport interface MergeResult {\n /** All unique articles after deduplication */\n articles: Article[];\n /** Articles grouped by provider */\n byProvider: Map<ProviderName, Article[]>;\n /** Total article count before deduplication */\n totalBefore: number;\n /** Total unique article count after deduplication */\n totalAfter: number;\n /** Number of duplicates removed */\n duplicatesRemoved: number;\n /** Per-session article counts (before dedup) */\n perSession: Map<string, number>;\n}\n\n/**\n * Options for creating a merged session.\n */\nexport interface CreateMergedSessionOptions {\n name: string;\n sources: SessionSource[];\n byProvider: Map<ProviderName, Article[]>;\n totalRetrieved: number;\n sessionsDir: string;\n sourceSessionIds: string[];\n}\n\n/**\n * Validation result for merge sources.\n */\nexport interface MergeValidationResult {\n valid: boolean;\n error?: string;\n expandedCommand?: string;\n}\n\n/**\n * Output data for formatting merge results.\n */\nexport interface MergeOutputData {\n sessionId: string;\n totalBefore: number;\n totalAfter: number;\n duplicatesRemoved: number;\n sources: Array<{ id: string; name: string; count: number }>;\n byProvider: Map<string, number>;\n}\n\n/**\n * Count metadata fields for comparing article richness.\n */\nfunction countMetadataFields(article: Article): number {\n let count = 0;\n if (article.doi) count++;\n if (article.pmid) count++;\n if (article.arxivId) count++;\n if (article.scopusId) count++;\n if (article.ericId) count++;\n if (article.abstract) count++;\n if (article.publicationDate) count++;\n if (article.journal) count++;\n if (article.volume) count++;\n if (article.issue) count++;\n if (article.pages) count++;\n if (article.authors.length > 0) count++;\n return count;\n}\n\n/**\n * Merge articles from multiple sessions with identifier-based deduplication.\n *\n * When duplicates are found (same DOI, PMID, etc.), the article with\n * richer metadata is kept.\n */\nexport function mergeArticles(\n sessionArticles: Map<string, Article[]>,\n): MergeResult {\n const keyToIndex = new Map<string, number>();\n const unique: Article[] = [];\n let totalBefore = 0;\n let duplicatesRemoved = 0;\n const perSession = new Map<string, number>();\n\n for (const [sessionId, articles] of sessionArticles) {\n perSession.set(sessionId, articles.length);\n totalBefore += articles.length;\n\n for (const article of articles) {\n const keys = getArticleKeys(article);\n\n if (keys.length === 0) {\n unique.push(article);\n continue;\n }\n\n let existingIndex: number | undefined;\n for (const key of keys) {\n const idx = keyToIndex.get(key);\n if (idx !== undefined) {\n existingIndex = idx;\n break;\n }\n }\n\n if (existingIndex !== undefined) {\n const existing = unique[existingIndex]!;\n if (countMetadataFields(article) > countMetadataFields(existing)) {\n unique[existingIndex] = article;\n const newKeys = getArticleKeys(article);\n for (const key of newKeys) {\n keyToIndex.set(key, existingIndex);\n }\n }\n duplicatesRemoved++;\n } else {\n const index = unique.length;\n unique.push(article);\n for (const key of keys) {\n keyToIndex.set(key, index);\n }\n }\n }\n }\n\n // Group by provider\n const byProvider = new Map<ProviderName, Article[]>();\n for (const article of unique) {\n const existing = byProvider.get(article.source) ?? [];\n existing.push(article);\n byProvider.set(article.source, existing);\n }\n\n return {\n articles: unique,\n byProvider,\n totalBefore,\n totalAfter: unique.length,\n duplicatesRemoved,\n perSession,\n };\n}\n\n/**\n * Copy source session provenance files to sources/ subdirectory.\n * Copies session.yaml, query_common.yaml, and query text files.\n */\nexport async function copySourceProvenance(\n sourceSessionId: string,\n sessionsDir: string,\n mergedSessionDir: string,\n): Promise<void> {\n const sourceDir = join(sessionsDir, sourceSessionId);\n const targetDir = join(mergedSessionDir, 'sources', sourceSessionId);\n await mkdir(targetDir, { recursive: true });\n\n // Copy session.yaml and query_common.yaml\n for (const file of ['session.yaml', 'query_common.yaml']) {\n try {\n await copyFile(join(sourceDir, file), join(targetDir, file));\n } catch {\n // Skip if file doesn't exist\n }\n }\n\n // Copy query text files (query_*.txt)\n const entries = await readdir(sourceDir);\n for (const entry of entries) {\n if (entry.startsWith('query_') && entry.endsWith('.txt')) {\n await copyFile(join(sourceDir, entry), join(targetDir, entry));\n }\n }\n}\n\n/**\n * Validate that all source sessions are valid for merging.\n */\nexport function validateMergeSources(\n sessions: Map<string, SessionFile>,\n): MergeValidationResult {\n // Check for merged sessions as sources\n for (const [sessionId, session] of sessions) {\n if (isMergedSession(session)) {\n // Collect original sources from the merged session\n const originalSources = session.sources?.map((s) => s.id) ?? [];\n // Collect non-merged session IDs\n const otherIds = [...sessions.keys()].filter((id) => id !== sessionId);\n // Build expanded command with original sources + other sessions\n const allSources = [...new Set([...originalSources, ...otherIds])];\n const expandedCommand = `search-hub merge ${allSources.join(' ')}`;\n\n return {\n valid: false,\n error: `Session '${sessionId}' is a merged session (sources: ${originalSources.join(', ')}).\\n Merge the original sources directly:\\n ${expandedCommand}`,\n expandedCommand,\n };\n }\n }\n\n // Check that all sessions are completed\n for (const [sessionId, session] of sessions) {\n if (session.summary.status !== 'completed') {\n return {\n valid: false,\n error: `Session '${sessionId}' is not completed (status: ${session.summary.status}). Only completed sessions can be merged.`,\n };\n }\n }\n\n return { valid: true };\n}\n\n/**\n * Generate a session ID for a merged session.\n */\nfunction generateMergedSessionId(name: string, sourceIds: string[]): string {\n const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');\n const sanitized = sanitizeName(name);\n const hash = createHash('sha256').update(sourceIds.join(',')).digest('hex').slice(0, 6);\n return `${date}_${sanitized}_${hash}`;\n}\n\n/**\n * Create a merged session on disk.\n */\nexport async function createMergedSession(\n options: CreateMergedSessionOptions,\n): Promise<SessionFile> {\n const { name, sources, byProvider, totalRetrieved, sessionsDir, sourceSessionIds } = options;\n\n const id = generateMergedSessionId(name, sourceSessionIds);\n const sessionDir = join(sessionsDir, id);\n const now = new Date().toISOString();\n\n await mkdir(sessionDir, { recursive: true });\n\n // Build database statuses and write result files\n const databases: Partial<Record<ProviderName, DatabaseStatus>> = {};\n\n for (const [provider, articles] of byProvider) {\n const jsonlFilename = `${provider}_results.jsonl`;\n const yamlFilename = `${provider}_results.yaml`;\n const jsonlPath = join(sessionDir, jsonlFilename);\n\n // Write JSONL results\n const jsonlContent = articles\n .map((a) => JSON.stringify(a))\n .join('\\n') + '\\n';\n await writeFile(jsonlPath, jsonlContent, 'utf-8');\n\n // Convert to YAML\n const yamlPath = join(sessionDir, yamlFilename);\n await convertResultsToYaml(jsonlPath, yamlPath, {\n provider,\n queryName: name,\n });\n\n databases[provider] = {\n status: 'completed',\n retrievedCount: articles.length,\n files: {\n query: '',\n results: jsonlFilename,\n resultsYaml: yamlFilename,\n },\n };\n }\n\n // Create session file\n const sessionFile: SessionFile = {\n version: 1,\n id,\n name,\n type: 'merge',\n createdAt: now,\n updatedAt: now,\n sources,\n databases,\n summary: {\n totalHits: 0,\n totalRetrieved: totalRetrieved,\n status: 'completed',\n },\n };\n\n // Write session.yaml\n await writeFile(\n join(sessionDir, 'session.yaml'),\n stringifyYaml(sessionFile),\n 'utf-8',\n );\n\n // Copy provenance from source sessions\n for (const sourceId of sourceSessionIds) {\n await copySourceProvenance(sourceId, sessionsDir, sessionDir);\n }\n\n return sessionFile;\n}\n\n/**\n * Format merge result as human-readable text.\n */\nexport function formatMergeOutput(data: MergeOutputData): string {\n const lines: string[] = [];\n\n lines.push(`Merged session: ${data.sessionId}`);\n lines.push('');\n lines.push('Sources:');\n for (const source of data.sources) {\n lines.push(` ${source.id} (${source.name}): ${source.count} articles`);\n }\n lines.push('');\n lines.push(`Total articles: ${data.totalBefore} → ${data.totalAfter} unique (${data.duplicatesRemoved} duplicates removed)`);\n\n if (data.byProvider.size > 0) {\n lines.push('');\n lines.push('By database:');\n for (const [provider, count] of data.byProvider) {\n lines.push(` ${provider}: ${count} articles`);\n }\n }\n\n return lines.join('\\n');\n}\n\n/**\n * Format merge result as JSON.\n */\nexport function formatMergeJson(data: MergeOutputData): string {\n return JSON.stringify({\n sessionId: data.sessionId,\n totalBefore: data.totalBefore,\n totalAfter: data.totalAfter,\n duplicatesRemoved: data.duplicatesRemoved,\n sources: data.sources,\n byProvider: Object.fromEntries(data.byProvider),\n }, null, 2);\n}\n"],"names":["stringifyYaml"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"resume.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/resume.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAG9E,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,YAAY,EAAE,CAAC;IAC3B,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,kBAAkB;IACjC,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxB,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACnC;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,kBAAkB,GAC1B,oBAAoB,CActB;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,gBAAgB,CASnF;AAED,wBAAsB,+BAA+B,CACnD,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE;IAAE,SAAS,CAAC,EAAE,YAAY,EAAE,GAAG,SAAS,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;CAAE,GACrF,OAAO,CAAC,YAAY,CAAC,CA0BvB"}
1
+ {"version":3,"file":"resume.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/resume.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAI9E,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,YAAY,EAAE,CAAC;IAC3B,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,kBAAkB;IACjC,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxB,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACnC;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,kBAAkB,GAC1B,oBAAoB,CActB;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,gBAAgB,CASnF;AAED,wBAAsB,+BAA+B,CACnD,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE;IAAE,SAAS,CAAC,EAAE,YAAY,EAAE,GAAG,SAAS,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;CAAE,GACrF,OAAO,CAAC,YAAY,CAAC,CAmCvB"}
@@ -1,4 +1,5 @@
1
1
  import { loadSession, getResumableProviders } from "../../session/manager.js";
2
+ import { isMergedSession } from "../../session/types.js";
2
3
  import { parseProviderNames } from "../utils/validation.js";
3
4
  function parseResumeOptions(sessionId, options) {
4
5
  const result = {
@@ -24,6 +25,12 @@ function validateResumeInput(options) {
24
25
  async function getResumableProvidersForCommand(sessionId, sessionsDir, options) {
25
26
  try {
26
27
  const session = await loadSession(sessionId, sessionsDir);
28
+ if (isMergedSession(session)) {
29
+ return {
30
+ success: false,
31
+ error: `Cannot resume merged session '${sessionId}'. Merged sessions are created from other sessions and cannot be resumed.`
32
+ };
33
+ }
27
34
  let resumable = getResumableProviders(session);
28
35
  if (options.providers && options.providers.length > 0) {
29
36
  resumable = resumable.filter((r) => options.providers.includes(r.provider));
@@ -1 +1 @@
1
- {"version":3,"file":"resume.js","sources":["../../../src/cli/commands/resume.ts"],"sourcesContent":["import { loadSession, getResumableProviders } from '../../session/manager.js';\nimport type { ProviderName, ResumableProvider } from '../../session/types.js';\nimport { parseProviderNames } from '../utils/validation.js';\n\nexport interface ResumeCommandOptions {\n sessionId: string;\n providers?: ProviderName[];\n retryFailed?: boolean;\n}\n\nexport interface CommandLineOptions {\n db?: string | undefined;\n retryFailed?: boolean | undefined;\n}\n\nexport interface ValidationResult {\n valid: boolean;\n error?: string;\n}\n\nexport interface ResumeResult {\n success: boolean;\n providers?: ResumableProvider[];\n error?: string;\n}\n\nexport function parseResumeOptions(\n sessionId: string,\n options: CommandLineOptions\n): ResumeCommandOptions {\n const result: ResumeCommandOptions = {\n sessionId,\n };\n\n if (options.db) {\n result.providers = parseProviderNames(options.db);\n }\n\n if (options.retryFailed) {\n result.retryFailed = true;\n }\n\n return result;\n}\n\nexport function validateResumeInput(options: ResumeCommandOptions): ValidationResult {\n if (!options.sessionId || options.sessionId.trim() === '') {\n return {\n valid: false,\n error: 'A session ID is required',\n };\n }\n\n return { valid: true };\n}\n\nexport async function getResumableProvidersForCommand(\n sessionId: string,\n sessionsDir: string,\n options: { providers?: ProviderName[] | undefined; retryFailed?: boolean | undefined }\n): Promise<ResumeResult> {\n try {\n const session = await loadSession(sessionId, sessionsDir);\n let resumable = getResumableProviders(session);\n\n // Filter by specific providers if requested\n if (options.providers && options.providers.length > 0) {\n resumable = resumable.filter((r) => options.providers!.includes(r.provider));\n }\n\n // Filter to only retry strategies if retryFailed is true\n if (options.retryFailed) {\n resumable = resumable.filter((r) => r.strategy === 'retry');\n }\n\n return {\n success: true,\n providers: resumable,\n };\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown error';\n return {\n success: false,\n error: message,\n };\n }\n}\n"],"names":[],"mappings":";;AA0BO,SAAS,mBACd,WACA,SACsB;AACtB,QAAM,SAA+B;AAAA,IACnC;AAAA,EAAA;AAGF,MAAI,QAAQ,IAAI;AACd,WAAO,YAAY,mBAAmB,QAAQ,EAAE;AAAA,EAClD;AAEA,MAAI,QAAQ,aAAa;AACvB,WAAO,cAAc;AAAA,EACvB;AAEA,SAAO;AACT;AAEO,SAAS,oBAAoB,SAAiD;AACnF,MAAI,CAAC,QAAQ,aAAa,QAAQ,UAAU,KAAA,MAAW,IAAI;AACzD,WAAO;AAAA,MACL,OAAO;AAAA,MACP,OAAO;AAAA,IAAA;AAAA,EAEX;AAEA,SAAO,EAAE,OAAO,KAAA;AAClB;AAEA,eAAsB,gCACpB,WACA,aACA,SACuB;AACvB,MAAI;AACF,UAAM,UAAU,MAAM,YAAY,WAAW,WAAW;AACxD,QAAI,YAAY,sBAAsB,OAAO;AAG7C,QAAI,QAAQ,aAAa,QAAQ,UAAU,SAAS,GAAG;AACrD,kBAAY,UAAU,OAAO,CAAC,MAAM,QAAQ,UAAW,SAAS,EAAE,QAAQ,CAAC;AAAA,IAC7E;AAGA,QAAI,QAAQ,aAAa;AACvB,kBAAY,UAAU,OAAO,CAAC,MAAM,EAAE,aAAa,OAAO;AAAA,IAC5D;AAEA,WAAO;AAAA,MACL,SAAS;AAAA,MACT,WAAW;AAAA,IAAA;AAAA,EAEf,SAAS,OAAO;AACd,UAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;AACzD,WAAO;AAAA,MACL,SAAS;AAAA,MACT,OAAO;AAAA,IAAA;AAAA,EAEX;AACF;"}
1
+ {"version":3,"file":"resume.js","sources":["../../../src/cli/commands/resume.ts"],"sourcesContent":["import { loadSession, getResumableProviders } from '../../session/manager.js';\nimport type { ProviderName, ResumableProvider } from '../../session/types.js';\nimport { isMergedSession } from '../../session/types.js';\nimport { parseProviderNames } from '../utils/validation.js';\n\nexport interface ResumeCommandOptions {\n sessionId: string;\n providers?: ProviderName[];\n retryFailed?: boolean;\n}\n\nexport interface CommandLineOptions {\n db?: string | undefined;\n retryFailed?: boolean | undefined;\n}\n\nexport interface ValidationResult {\n valid: boolean;\n error?: string;\n}\n\nexport interface ResumeResult {\n success: boolean;\n providers?: ResumableProvider[];\n error?: string;\n}\n\nexport function parseResumeOptions(\n sessionId: string,\n options: CommandLineOptions\n): ResumeCommandOptions {\n const result: ResumeCommandOptions = {\n sessionId,\n };\n\n if (options.db) {\n result.providers = parseProviderNames(options.db);\n }\n\n if (options.retryFailed) {\n result.retryFailed = true;\n }\n\n return result;\n}\n\nexport function validateResumeInput(options: ResumeCommandOptions): ValidationResult {\n if (!options.sessionId || options.sessionId.trim() === '') {\n return {\n valid: false,\n error: 'A session ID is required',\n };\n }\n\n return { valid: true };\n}\n\nexport async function getResumableProvidersForCommand(\n sessionId: string,\n sessionsDir: string,\n options: { providers?: ProviderName[] | undefined; retryFailed?: boolean | undefined }\n): Promise<ResumeResult> {\n try {\n const session = await loadSession(sessionId, sessionsDir);\n\n // Reject merged sessions\n if (isMergedSession(session)) {\n return {\n success: false,\n error: `Cannot resume merged session '${sessionId}'. Merged sessions are created from other sessions and cannot be resumed.`,\n };\n }\n\n let resumable = getResumableProviders(session);\n\n // Filter by specific providers if requested\n if (options.providers && options.providers.length > 0) {\n resumable = resumable.filter((r) => options.providers!.includes(r.provider));\n }\n\n // Filter to only retry strategies if retryFailed is true\n if (options.retryFailed) {\n resumable = resumable.filter((r) => r.strategy === 'retry');\n }\n\n return {\n success: true,\n providers: resumable,\n };\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown error';\n return {\n success: false,\n error: message,\n };\n }\n}\n"],"names":[],"mappings":";;;AA2BO,SAAS,mBACd,WACA,SACsB;AACtB,QAAM,SAA+B;AAAA,IACnC;AAAA,EAAA;AAGF,MAAI,QAAQ,IAAI;AACd,WAAO,YAAY,mBAAmB,QAAQ,EAAE;AAAA,EAClD;AAEA,MAAI,QAAQ,aAAa;AACvB,WAAO,cAAc;AAAA,EACvB;AAEA,SAAO;AACT;AAEO,SAAS,oBAAoB,SAAiD;AACnF,MAAI,CAAC,QAAQ,aAAa,QAAQ,UAAU,KAAA,MAAW,IAAI;AACzD,WAAO;AAAA,MACL,OAAO;AAAA,MACP,OAAO;AAAA,IAAA;AAAA,EAEX;AAEA,SAAO,EAAE,OAAO,KAAA;AAClB;AAEA,eAAsB,gCACpB,WACA,aACA,SACuB;AACvB,MAAI;AACF,UAAM,UAAU,MAAM,YAAY,WAAW,WAAW;AAGxD,QAAI,gBAAgB,OAAO,GAAG;AAC5B,aAAO;AAAA,QACL,SAAS;AAAA,QACT,OAAO,iCAAiC,SAAS;AAAA,MAAA;AAAA,IAErD;AAEA,QAAI,YAAY,sBAAsB,OAAO;AAG7C,QAAI,QAAQ,aAAa,QAAQ,UAAU,SAAS,GAAG;AACrD,kBAAY,UAAU,OAAO,CAAC,MAAM,QAAQ,UAAW,SAAS,EAAE,QAAQ,CAAC;AAAA,IAC7E;AAGA,QAAI,QAAQ,aAAa;AACvB,kBAAY,UAAU,OAAO,CAAC,MAAM,EAAE,aAAa,OAAO;AAAA,IAC5D;AAEA,WAAO;AAAA,MACL,SAAS;AAAA,MACT,WAAW;AAAA,IAAA;AAAA,EAEf,SAAS,OAAO;AACd,UAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;AACzD,WAAO;AAAA,MACL,SAAS;AAAA,MACT,OAAO;AAAA,IAAA;AAAA,EAEX;AACF;"}
@@ -7,12 +7,14 @@ export interface ReviewExtractOptions {
7
7
  seed?: number;
8
8
  limit?: number;
9
9
  offset?: number;
10
- /** Basis for the review (title, abstract). When specified, outputs work file format. */
10
+ /** Basis for the review (title, abstract, fulltext). When specified, outputs screening format. */
11
11
  basis?: ReviewBasis;
12
12
  /** Reviewer identifier (e.g., "ai:claude"). Required for all extract modes. */
13
13
  reviewer?: string;
14
14
  /** Name for the review subset (output goes to for-review/<name>/review.yaml) */
15
15
  name: string;
16
+ /** When true, outputs final decision format with reviewHistory and finalDecision fields. */
17
+ finalize?: boolean;
16
18
  }
17
19
  export interface ReviewExtractResult {
18
20
  outputPath: string;
@@ -1 +1 @@
1
- {"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/extract.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,OAAO,EAAsD,KAAK,YAAY,EAAE,KAAK,WAAW,EAAuC,MAAM,YAAY,CAAC;AAE1J,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE9D,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IACxB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wFAAwF;IACxF,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAC;CACd;AAGD,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;CACvB;AA4FD;;GAEG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAU/C;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,oBAAoB,EAC7B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,mBAAmB,CAAC,CA2H9B"}
1
+ {"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/extract.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,OAAO,EAAsD,KAAK,YAAY,EAAE,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AAErH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE9D,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IACxB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kGAAkG;IAClG,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAC;IACb,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAGD,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;CACvB;AAgLD;;GAEG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAU/C;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,oBAAoB,EAC7B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,mBAAmB,CAAC,CAuG9B"}
@@ -20,18 +20,12 @@ function seededShuffle(array, seed) {
20
20
  }
21
21
  return result;
22
22
  }
23
- function getArticleId(article) {
24
- if (article.doi) return article.doi;
25
- if (article.pmid) return article.pmid;
26
- if (article.scopusId) return article.scopusId;
27
- if (article.arxivId) return article.arxivId;
28
- if (article.ericId) return article.ericId;
29
- return article.title;
30
- }
31
23
  function getBasisGuidanceComment(basis) {
24
+ const schemaLine = "# yaml-language-server: $schema=./review.schema.json";
32
25
  switch (basis) {
33
26
  case "title":
34
27
  return [
28
+ schemaLine,
35
29
  "# Screening by title only.",
36
30
  '# Mark clearly irrelevant items as "exclude" with a comment explaining the reason.',
37
31
  '# Leave everything else as "uncertain".',
@@ -39,6 +33,7 @@ function getBasisGuidanceComment(basis) {
39
33
  ].join("\n");
40
34
  case "abstract":
41
35
  return [
36
+ schemaLine,
42
37
  "# Screening by title and abstract.",
43
38
  '# You should be able to decide "include" or "exclude" for most items at this stage.',
44
39
  '# Mark remaining ambiguous items as "uncertain" with a comment explaining why.',
@@ -46,6 +41,7 @@ function getBasisGuidanceComment(basis) {
46
41
  ].join("\n");
47
42
  case "fulltext":
48
43
  return [
44
+ schemaLine,
49
45
  "# Screening by full text. This is the final decision stage.",
50
46
  '# Decide "include" or "exclude" for each item.',
51
47
  '# Use "uncertain" only when absolutely unavoidable, with a comment explaining why.',
@@ -53,6 +49,59 @@ function getBasisGuidanceComment(basis) {
53
49
  ].join("\n");
54
50
  }
55
51
  }
52
+ function getDecisionInlineComment(basis) {
53
+ switch (basis) {
54
+ case "title":
55
+ return "# exclude / uncertain";
56
+ case "abstract":
57
+ case "fulltext":
58
+ return "# include / exclude / uncertain";
59
+ }
60
+ }
61
+ function buildScreeningArticle(article, basis) {
62
+ const result = { title: article.title, reviews: [] };
63
+ if (article.doi) result.doi = article.doi;
64
+ if (article.pmid) result.pmid = article.pmid;
65
+ if (article.scopusId) result.scopusId = article.scopusId;
66
+ if (article.arxivId) result.arxivId = article.arxivId;
67
+ if (article.ericId) result.ericId = article.ericId;
68
+ if ((basis === "abstract" || basis === "fulltext") && article.abstract) {
69
+ result.abstract = article.abstract;
70
+ }
71
+ if (basis === "fulltext" && article.fulltext) {
72
+ result.fulltext = article.fulltext;
73
+ }
74
+ result.reviews = [{ decision: "uncertain", comment: "" }];
75
+ return result;
76
+ }
77
+ function buildFinalizeArticle(article, basis) {
78
+ const result = { title: article.title, reviews: [] };
79
+ if (article.doi) result.doi = article.doi;
80
+ if (article.pmid) result.pmid = article.pmid;
81
+ if (article.scopusId) result.scopusId = article.scopusId;
82
+ if (article.arxivId) result.arxivId = article.arxivId;
83
+ if (article.ericId) result.ericId = article.ericId;
84
+ if (article.authors) result.authors = article.authors;
85
+ if (article.year) result.year = article.year;
86
+ if (!basis || basis === "abstract" || basis === "fulltext") {
87
+ if (article.abstract) result.abstract = article.abstract;
88
+ }
89
+ if (!basis || basis === "fulltext") {
90
+ if (article.fulltext) result.fulltext = article.fulltext;
91
+ }
92
+ result.reviewHistory = article.reviews ?? [];
93
+ result.reviews = [];
94
+ result.finalDecision = null;
95
+ return result;
96
+ }
97
+ function getFinalDecisionGuidanceComment() {
98
+ return [
99
+ "# yaml-language-server: $schema=./review.schema.json",
100
+ "# Final decision file: set finalDecision on each article",
101
+ "# Valid decisions: include / exclude / null",
102
+ ""
103
+ ].join("\n");
104
+ }
56
105
  function sortArticles(articles, sort, seed) {
57
106
  switch (sort) {
58
107
  case "year":
@@ -105,57 +154,38 @@ async function executeReviewExtract(options, sessionsDir) {
105
154
  if (options.limit !== void 0 && options.limit > 0) {
106
155
  paginated = paginated.slice(0, options.limit);
107
156
  }
157
+ if (!options.reviewer) {
158
+ throw new Error("--reviewer is required for review file extract");
159
+ }
108
160
  let finalContent;
109
- if (options.basis && options.reviewer) {
110
- const workFile = {
161
+ if (options.basis && !options.finalize) {
162
+ const outputFile = {
111
163
  sessionId: options.sessionId,
112
164
  basis: options.basis,
113
165
  reviewer: options.reviewer,
114
- articles: paginated.map((article) => {
115
- const workArticle = {
116
- id: getArticleId(article),
117
- title: article.title,
118
- decision: "uncertain",
119
- comment: ""
120
- };
121
- if ((options.basis === "abstract" || options.basis === "fulltext") && article.abstract) {
122
- workArticle.abstract = article.abstract;
123
- }
124
- if (options.basis === "fulltext" && article.fulltext) {
125
- workArticle.fulltext = article.fulltext.dirName;
126
- }
127
- return workArticle;
128
- })
166
+ articles: paginated.map((article) => buildScreeningArticle(article, options.basis))
129
167
  };
130
- const yamlContent = stringify(workFile, {
131
- lineWidth: 0
132
- });
168
+ const yamlContent = stringify(outputFile, { lineWidth: 0 });
169
+ const decisionComment = getDecisionInlineComment(options.basis);
170
+ const yamlWithComments = yamlContent.replace(
171
+ /^(\s*-?\s*)decision: uncertain$/gm,
172
+ `$1decision: uncertain ${decisionComment}`
173
+ );
133
174
  const guidanceComment = getBasisGuidanceComment(options.basis);
134
- finalContent = guidanceComment + yamlContent;
175
+ finalContent = guidanceComment + yamlWithComments;
135
176
  } else {
136
- if (!options.reviewer) {
137
- throw new Error("--reviewer is required for review file extract");
138
- }
139
177
  const outputFile = {
140
178
  sessionId: options.sessionId,
141
- ...options.reviewer && { reviewer: options.reviewer },
142
- articles: paginated.map((article) => ({
143
- ...article,
144
- reviewHistory: article.reviews ?? [],
145
- reviews: [],
146
- finalDecision: null
147
- }))
179
+ reviewer: options.reviewer,
180
+ articles: paginated.map((article) => buildFinalizeArticle(article, options.basis))
148
181
  };
149
- const yamlContent = stringify(outputFile, {
150
- lineWidth: 0
151
- });
182
+ const yamlContent = stringify(outputFile, { lineWidth: 0 });
152
183
  const yamlWithComments = yamlContent.replace(
153
184
  /^(\s*)finalDecision: null$/gm,
154
185
  "$1finalDecision: # include / exclude"
155
186
  );
156
- const schemaComment = `# yaml-language-server: $schema=./review.schema.json
157
- `;
158
- finalContent = schemaComment + yamlWithComments;
187
+ const guidanceComment = getFinalDecisionGuidanceComment();
188
+ finalContent = guidanceComment + yamlWithComments;
159
189
  }
160
190
  const outputDir = dirname(outputPath);
161
191
  await mkdir(outputDir, { recursive: true });