@ncukondo/search-hub 0.19.0 → 0.20.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/dist/cli/commands/related.d.ts +66 -0
- package/dist/cli/commands/related.d.ts.map +1 -0
- package/dist/cli/commands/related.js +161 -0
- package/dist/cli/commands/related.js.map +1 -0
- package/dist/cli/commands/review/extract.d.ts.map +1 -1
- package/dist/cli/commands/review/extract.js +15 -5
- package/dist/cli/commands/review/extract.js.map +1 -1
- package/dist/cli/commands/review/init.d.ts +2 -3
- package/dist/cli/commands/review/init.d.ts.map +1 -1
- package/dist/cli/commands/review/init.js +1 -0
- package/dist/cli/commands/review/init.js.map +1 -1
- package/dist/cli/commands/review/next-steps.d.ts +3 -0
- package/dist/cli/commands/review/next-steps.d.ts.map +1 -1
- package/dist/cli/commands/review/next-steps.js +53 -19
- package/dist/cli/commands/review/next-steps.js.map +1 -1
- package/dist/cli/commands/review/schema.d.ts +8 -0
- package/dist/cli/commands/review/schema.d.ts.map +1 -1
- package/dist/cli/commands/review/schema.js +3 -0
- package/dist/cli/commands/review/schema.js.map +1 -1
- package/dist/cli/commands/review/status.d.ts +3 -1
- package/dist/cli/commands/review/status.d.ts.map +1 -1
- package/dist/cli/commands/review/status.js +3 -1
- package/dist/cli/commands/review/status.js.map +1 -1
- package/dist/cli/commands/review/types.d.ts +2 -1
- package/dist/cli/commands/review/types.d.ts.map +1 -1
- package/dist/cli/commands/review/types.js.map +1 -1
- package/dist/cli/commands/search-executor.d.ts.map +1 -1
- package/dist/cli/commands/search-executor.js +3 -2
- package/dist/cli/commands/search-executor.js.map +1 -1
- package/dist/cli/commands/search.d.ts +2 -0
- package/dist/cli/commands/search.d.ts.map +1 -1
- package/dist/cli/commands/search.js +3 -0
- package/dist/cli/commands/search.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +128 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/suggestions/rules.d.ts.map +1 -1
- package/dist/cli/suggestions/rules.js +19 -3
- package/dist/cli/suggestions/rules.js.map +1 -1
- package/dist/providers/arxiv/provider.d.ts.map +1 -1
- package/dist/providers/arxiv/provider.js +7 -4
- package/dist/providers/arxiv/provider.js.map +1 -1
- package/dist/providers/base/types.d.ts +8 -0
- package/dist/providers/base/types.d.ts.map +1 -1
- package/dist/providers/base/types.js.map +1 -1
- package/dist/providers/eric/provider.d.ts +3 -0
- package/dist/providers/eric/provider.d.ts.map +1 -1
- package/dist/providers/eric/provider.js +11 -0
- package/dist/providers/eric/provider.js.map +1 -1
- package/dist/providers/pubmed/client.d.ts +15 -1
- package/dist/providers/pubmed/client.d.ts.map +1 -1
- package/dist/providers/pubmed/client.js +64 -1
- package/dist/providers/pubmed/client.js.map +1 -1
- package/dist/providers/pubmed/index.d.ts +2 -2
- package/dist/providers/pubmed/index.d.ts.map +1 -1
- package/dist/providers/pubmed/parser.d.ts +8 -1
- package/dist/providers/pubmed/parser.d.ts.map +1 -1
- package/dist/providers/pubmed/parser.js +23 -1
- package/dist/providers/pubmed/parser.js.map +1 -1
- package/dist/providers/pubmed/provider.d.ts.map +1 -1
- package/dist/providers/pubmed/provider.js +8 -2
- package/dist/providers/pubmed/provider.js.map +1 -1
- package/dist/providers/pubmed/types.d.ts +29 -0
- package/dist/providers/pubmed/types.d.ts.map +1 -1
- package/dist/providers/scopus/client.d.ts +2 -0
- package/dist/providers/scopus/client.d.ts.map +1 -1
- package/dist/providers/scopus/client.js +3 -0
- package/dist/providers/scopus/client.js.map +1 -1
- package/dist/providers/scopus/provider.d.ts.map +1 -1
- package/dist/providers/scopus/provider.js +7 -1
- package/dist/providers/scopus/provider.js.map +1 -1
- package/dist/session/types.d.ts +13 -1
- package/dist/session/types.d.ts.map +1 -1
- package/dist/session/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Article } from '../../providers/base/types.js';
|
|
2
|
+
import { SessionFile, SessionSeeds } from '../../session/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Parsed options for the related command.
|
|
5
|
+
*/
|
|
6
|
+
export interface RelatedCommandOptions {
|
|
7
|
+
pmids: string[];
|
|
8
|
+
name?: string;
|
|
9
|
+
maxResults: number;
|
|
10
|
+
fromSession?: string;
|
|
11
|
+
term?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* CLI option types from Commander.js.
|
|
15
|
+
*/
|
|
16
|
+
export interface CommandLineOptions {
|
|
17
|
+
name?: string;
|
|
18
|
+
maxResults?: string;
|
|
19
|
+
fromSession?: string;
|
|
20
|
+
pmid?: string | string[];
|
|
21
|
+
term?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface ValidationResult {
|
|
24
|
+
valid: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Options for creating a related session.
|
|
29
|
+
*/
|
|
30
|
+
export interface CreateRelatedSessionOptions {
|
|
31
|
+
name: string;
|
|
32
|
+
seeds: SessionSeeds;
|
|
33
|
+
articles: Article[];
|
|
34
|
+
sessionsDir: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Data for formatting related output.
|
|
38
|
+
*/
|
|
39
|
+
export interface RelatedOutputData {
|
|
40
|
+
sessionId: string;
|
|
41
|
+
seedCount: number;
|
|
42
|
+
totalRelated: number;
|
|
43
|
+
retrievedCount: number;
|
|
44
|
+
articles: Article[];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Parse command line options into RelatedCommandOptions.
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseRelatedOptions(pmidArgs: string[], options: CommandLineOptions): RelatedCommandOptions;
|
|
50
|
+
/**
|
|
51
|
+
* Validate related command input.
|
|
52
|
+
*/
|
|
53
|
+
export declare function validateRelatedInput(options: RelatedCommandOptions): ValidationResult;
|
|
54
|
+
/**
|
|
55
|
+
* Resolve seed PMIDs from options and/or existing session.
|
|
56
|
+
*/
|
|
57
|
+
export declare function resolveSeeds(options: RelatedCommandOptions, sessionsDir: string): Promise<string[]>;
|
|
58
|
+
/**
|
|
59
|
+
* Create a related session on disk.
|
|
60
|
+
*/
|
|
61
|
+
export declare function createRelatedSession(options: CreateRelatedSessionOptions): Promise<SessionFile>;
|
|
62
|
+
/**
|
|
63
|
+
* Format related search output for display.
|
|
64
|
+
*/
|
|
65
|
+
export declare function formatRelatedOutput(data: RelatedOutputData): string;
|
|
66
|
+
//# sourceMappingURL=related.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"related.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/related.ts"],"names":[],"mappings":"AAAA;;GAEG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,+BAA+B,CAAC;AAC7D,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAOxE;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;GAEG;AACH,MAAM,WAAW,2BAA2B;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,YAAY,CAAC;IACpB,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,MAAM,EAAE,EAClB,OAAO,EAAE,kBAAkB,GAC1B,qBAAqB,CA6BvB;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,qBAAqB,GAAG,gBAAgB,CA0BrF;AAED;;GAEG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,EAAE,CAAC,CA+BnB;AAYD;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,2BAA2B,GACnC,OAAO,CAAC,WAAW,CAAC,CAgEtB;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CA0BnE"}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { stringify } from "yaml";
|
|
5
|
+
import { loadSession, sanitizeName } from "../../session/manager.js";
|
|
6
|
+
import { convertResultsToYaml } from "../../session/results-io.js";
|
|
7
|
+
import { loadSessionArticles } from "./session-utils.js";
|
|
8
|
+
function parseRelatedOptions(pmidArgs, options) {
|
|
9
|
+
const result = {
|
|
10
|
+
pmids: [...pmidArgs],
|
|
11
|
+
maxResults: 20
|
|
12
|
+
};
|
|
13
|
+
if (options.pmid) {
|
|
14
|
+
const pmidOption = Array.isArray(options.pmid) ? options.pmid : [options.pmid];
|
|
15
|
+
result.pmids = [...result.pmids, ...pmidOption];
|
|
16
|
+
}
|
|
17
|
+
if (options.name) {
|
|
18
|
+
result.name = options.name;
|
|
19
|
+
}
|
|
20
|
+
if (options.maxResults) {
|
|
21
|
+
result.maxResults = parseInt(options.maxResults, 10);
|
|
22
|
+
}
|
|
23
|
+
if (options.fromSession) {
|
|
24
|
+
result.fromSession = options.fromSession;
|
|
25
|
+
}
|
|
26
|
+
if (options.term) {
|
|
27
|
+
result.term = options.term;
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
function validateRelatedInput(options) {
|
|
32
|
+
if (options.pmids.length === 0 && !options.fromSession) {
|
|
33
|
+
return {
|
|
34
|
+
valid: false,
|
|
35
|
+
error: "At least one PMID is required. Provide PMIDs as arguments or use --from-session."
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
for (const pmid of options.pmids) {
|
|
39
|
+
if (!/^\d+$/.test(pmid)) {
|
|
40
|
+
return {
|
|
41
|
+
valid: false,
|
|
42
|
+
error: `Invalid PMID format: "${pmid}". PMIDs must be numeric.`
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (options.maxResults <= 0) {
|
|
47
|
+
return {
|
|
48
|
+
valid: false,
|
|
49
|
+
error: "--max-results must be a positive number."
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return { valid: true };
|
|
53
|
+
}
|
|
54
|
+
async function resolveSeeds(options, sessionsDir) {
|
|
55
|
+
if (!options.fromSession) {
|
|
56
|
+
return options.pmids;
|
|
57
|
+
}
|
|
58
|
+
const session = await loadSession(options.fromSession, sessionsDir);
|
|
59
|
+
const articles = await loadSessionArticles(session, options.fromSession, sessionsDir);
|
|
60
|
+
const sessionPmids = /* @__PURE__ */ new Set();
|
|
61
|
+
for (const article of articles) {
|
|
62
|
+
if (article.pmid) {
|
|
63
|
+
sessionPmids.add(article.pmid);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (options.pmids.length === 0) {
|
|
67
|
+
return [...sessionPmids];
|
|
68
|
+
}
|
|
69
|
+
const missing = options.pmids.filter((pmid) => !sessionPmids.has(pmid));
|
|
70
|
+
if (missing.length > 0) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`PMIDs not found in session "${options.fromSession}": ${missing.join(", ")}`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return options.pmids;
|
|
76
|
+
}
|
|
77
|
+
function generateRelatedSessionId(name, seeds) {
|
|
78
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10).replace(/-/g, "");
|
|
79
|
+
const sanitized = sanitizeName(name);
|
|
80
|
+
const hash = createHash("sha256").update(seeds.join(",")).digest("hex").slice(0, 6);
|
|
81
|
+
return `${date}_${sanitized}_${hash}`;
|
|
82
|
+
}
|
|
83
|
+
async function createRelatedSession(options) {
|
|
84
|
+
const { name, seeds, articles, sessionsDir } = options;
|
|
85
|
+
const id = generateRelatedSessionId(name, seeds.ids);
|
|
86
|
+
const sessionDir = join(sessionsDir, id);
|
|
87
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
88
|
+
await mkdir(sessionDir, { recursive: true });
|
|
89
|
+
const jsonlFilename = "pubmed_results.jsonl";
|
|
90
|
+
const yamlFilename = "pubmed_results.yaml";
|
|
91
|
+
const jsonlPath = join(sessionDir, jsonlFilename);
|
|
92
|
+
const jsonlContent = articles.map((a) => JSON.stringify(a)).join("\n") + "\n";
|
|
93
|
+
await writeFile(jsonlPath, jsonlContent, "utf-8");
|
|
94
|
+
const yamlPath = join(sessionDir, yamlFilename);
|
|
95
|
+
await convertResultsToYaml(jsonlPath, yamlPath, {
|
|
96
|
+
provider: "pubmed",
|
|
97
|
+
queryName: name
|
|
98
|
+
});
|
|
99
|
+
const databases = {
|
|
100
|
+
pubmed: {
|
|
101
|
+
status: "completed",
|
|
102
|
+
retrievedCount: articles.length,
|
|
103
|
+
files: {
|
|
104
|
+
query: "",
|
|
105
|
+
results: jsonlFilename,
|
|
106
|
+
resultsYaml: yamlFilename
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const sessionFile = {
|
|
111
|
+
version: 1,
|
|
112
|
+
id,
|
|
113
|
+
name,
|
|
114
|
+
type: "related",
|
|
115
|
+
createdAt: now,
|
|
116
|
+
updatedAt: now,
|
|
117
|
+
seeds,
|
|
118
|
+
databases,
|
|
119
|
+
summary: {
|
|
120
|
+
totalHits: 0,
|
|
121
|
+
totalRetrieved: articles.length,
|
|
122
|
+
status: "completed"
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
await writeFile(
|
|
126
|
+
join(sessionDir, "session.yaml"),
|
|
127
|
+
stringify(sessionFile),
|
|
128
|
+
"utf-8"
|
|
129
|
+
);
|
|
130
|
+
return sessionFile;
|
|
131
|
+
}
|
|
132
|
+
function formatRelatedOutput(data) {
|
|
133
|
+
const lines = [];
|
|
134
|
+
lines.push(`Related session: ${data.sessionId}`);
|
|
135
|
+
lines.push("");
|
|
136
|
+
lines.push(`Seeds: ${data.seedCount} PMIDs`);
|
|
137
|
+
lines.push(`Related found: ${data.totalRelated}`);
|
|
138
|
+
lines.push(`Retrieved: ${data.retrievedCount}`);
|
|
139
|
+
if (data.articles.length > 0) {
|
|
140
|
+
lines.push("");
|
|
141
|
+
lines.push("Top results:");
|
|
142
|
+
const maxDisplay = Math.min(data.articles.length, 10);
|
|
143
|
+
for (let i = 0; i < maxDisplay; i++) {
|
|
144
|
+
const article = data.articles[i];
|
|
145
|
+
const title = article.title.length > 70 ? article.title.substring(0, 67) + "..." : article.title;
|
|
146
|
+
lines.push(` ${i + 1}. ${title}`);
|
|
147
|
+
}
|
|
148
|
+
if (data.articles.length > maxDisplay) {
|
|
149
|
+
lines.push(` ... and ${data.articles.length - maxDisplay} more`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return lines.join("\n");
|
|
153
|
+
}
|
|
154
|
+
export {
|
|
155
|
+
createRelatedSession,
|
|
156
|
+
formatRelatedOutput,
|
|
157
|
+
parseRelatedOptions,
|
|
158
|
+
resolveSeeds,
|
|
159
|
+
validateRelatedInput
|
|
160
|
+
};
|
|
161
|
+
//# sourceMappingURL=related.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"related.js","sources":["../../../src/cli/commands/related.ts"],"sourcesContent":["/**\n * Related command for finding related articles via PubMed ELink.\n */\n\nimport { mkdir, writeFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { createHash } from 'node:crypto';\nimport { stringify as stringifyYaml } from 'yaml';\nimport type { Article } from '../../providers/base/types.js';\nimport type { SessionFile, SessionSeeds } from '../../session/types.js';\nimport type { DatabaseStatus } from '../../session/types.js';\nimport { loadSession } from '../../session/manager.js';\nimport { sanitizeName } from '../../session/manager.js';\nimport { convertResultsToYaml } from '../../session/results-io.js';\nimport { loadSessionArticles } from './session-utils.js';\n\n/**\n * Parsed options for the related command.\n */\nexport interface RelatedCommandOptions {\n pmids: string[];\n name?: string;\n maxResults: number;\n fromSession?: string;\n term?: string;\n}\n\n/**\n * CLI option types from Commander.js.\n */\nexport interface CommandLineOptions {\n name?: string;\n maxResults?: string;\n fromSession?: string;\n pmid?: string | string[];\n term?: string;\n}\n\nexport interface ValidationResult {\n valid: boolean;\n error?: string;\n}\n\n/**\n * Options for creating a related session.\n */\nexport interface CreateRelatedSessionOptions {\n name: string;\n seeds: SessionSeeds;\n articles: Article[];\n sessionsDir: string;\n}\n\n/**\n * Data for formatting related output.\n */\nexport interface RelatedOutputData {\n sessionId: string;\n seedCount: number;\n totalRelated: number;\n retrievedCount: number;\n articles: Article[];\n}\n\n/**\n * Parse command line options into RelatedCommandOptions.\n */\nexport function parseRelatedOptions(\n pmidArgs: string[],\n options: CommandLineOptions,\n): RelatedCommandOptions {\n const result: RelatedCommandOptions = {\n pmids: [...pmidArgs],\n maxResults: 20,\n };\n\n // --pmid option (alternative to positional args, required with --from-session)\n if (options.pmid) {\n const pmidOption = Array.isArray(options.pmid) ? options.pmid : [options.pmid];\n result.pmids = [...result.pmids, ...pmidOption];\n }\n\n if (options.name) {\n result.name = options.name;\n }\n\n if (options.maxResults) {\n result.maxResults = parseInt(options.maxResults, 10);\n }\n\n if (options.fromSession) {\n result.fromSession = options.fromSession;\n }\n\n if (options.term) {\n result.term = options.term;\n }\n\n return result;\n}\n\n/**\n * Validate related command input.\n */\nexport function validateRelatedInput(options: RelatedCommandOptions): ValidationResult {\n if (options.pmids.length === 0 && !options.fromSession) {\n return {\n valid: false,\n error: 'At least one PMID is required. Provide PMIDs as arguments or use --from-session.',\n };\n }\n\n // Validate PMID format (numeric strings)\n for (const pmid of options.pmids) {\n if (!/^\\d+$/.test(pmid)) {\n return {\n valid: false,\n error: `Invalid PMID format: \"${pmid}\". PMIDs must be numeric.`,\n };\n }\n }\n\n if (options.maxResults <= 0) {\n return {\n valid: false,\n error: '--max-results must be a positive number.',\n };\n }\n\n return { valid: true };\n}\n\n/**\n * Resolve seed PMIDs from options and/or existing session.\n */\nexport async function resolveSeeds(\n options: RelatedCommandOptions,\n sessionsDir: string,\n): Promise<string[]> {\n if (!options.fromSession) {\n return options.pmids;\n }\n\n // Load articles from the source session\n const session = await loadSession(options.fromSession, sessionsDir);\n const articles = await loadSessionArticles(session, options.fromSession, sessionsDir);\n\n // Extract PMIDs from session articles\n const sessionPmids = new Set<string>();\n for (const article of articles) {\n if (article.pmid) {\n sessionPmids.add(article.pmid);\n }\n }\n\n if (options.pmids.length === 0) {\n // No specific PMIDs requested, return all from session\n return [...sessionPmids];\n }\n\n // Validate that requested PMIDs exist in the session\n const missing = options.pmids.filter(pmid => !sessionPmids.has(pmid));\n if (missing.length > 0) {\n throw new Error(\n `PMIDs not found in session \"${options.fromSession}\": ${missing.join(', ')}`\n );\n }\n\n return options.pmids;\n}\n\n/**\n * Generate a session ID for a related session.\n */\nfunction generateRelatedSessionId(name: string, seeds: string[]): string {\n const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');\n const sanitized = sanitizeName(name);\n const hash = createHash('sha256').update(seeds.join(',')).digest('hex').slice(0, 6);\n return `${date}_${sanitized}_${hash}`;\n}\n\n/**\n * Create a related session on disk.\n */\nexport async function createRelatedSession(\n options: CreateRelatedSessionOptions,\n): Promise<SessionFile> {\n const { name, seeds, articles, sessionsDir } = options;\n\n const id = generateRelatedSessionId(name, seeds.ids);\n const sessionDir = join(sessionsDir, id);\n const now = new Date().toISOString();\n\n await mkdir(sessionDir, { recursive: true });\n\n // Write JSONL results (use {provider}_results convention for loadResults compatibility)\n const jsonlFilename = 'pubmed_results.jsonl';\n const yamlFilename = 'pubmed_results.yaml';\n const jsonlPath = join(sessionDir, jsonlFilename);\n\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: 'pubmed',\n queryName: name,\n });\n\n // Build database status\n const databases: Partial<Record<string, DatabaseStatus>> = {\n pubmed: {\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: 'related',\n createdAt: now,\n updatedAt: now,\n seeds,\n databases,\n summary: {\n totalHits: 0,\n totalRetrieved: articles.length,\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 return sessionFile;\n}\n\n/**\n * Format related search output for display.\n */\nexport function formatRelatedOutput(data: RelatedOutputData): string {\n const lines: string[] = [];\n\n lines.push(`Related session: ${data.sessionId}`);\n lines.push('');\n lines.push(`Seeds: ${data.seedCount} PMIDs`);\n lines.push(`Related found: ${data.totalRelated}`);\n lines.push(`Retrieved: ${data.retrievedCount}`);\n\n if (data.articles.length > 0) {\n lines.push('');\n lines.push('Top results:');\n const maxDisplay = Math.min(data.articles.length, 10);\n for (let i = 0; i < maxDisplay; i++) {\n const article = data.articles[i]!;\n const title = article.title.length > 70\n ? article.title.substring(0, 67) + '...'\n : article.title;\n lines.push(` ${i + 1}. ${title}`);\n }\n if (data.articles.length > maxDisplay) {\n lines.push(` ... and ${data.articles.length - maxDisplay} more`);\n }\n }\n\n return lines.join('\\n');\n}\n"],"names":["stringifyYaml"],"mappings":"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"}
|
|
@@ -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,
|
|
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,EAAmB,MAAM,YAAY,CAAC;AAEtI,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;AAyLD;;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,CAqH9B"}
|
|
@@ -20,10 +20,19 @@ function seededShuffle(array, seed) {
|
|
|
20
20
|
}
|
|
21
21
|
return result;
|
|
22
22
|
}
|
|
23
|
-
function getBasisGuidanceComment(basis) {
|
|
23
|
+
function getBasisGuidanceComment(basis, mode = "screening") {
|
|
24
24
|
const schemaLine = "# yaml-language-server: $schema=./review.schema.json";
|
|
25
25
|
switch (basis) {
|
|
26
26
|
case "title":
|
|
27
|
+
if (mode === "picking") {
|
|
28
|
+
return [
|
|
29
|
+
schemaLine,
|
|
30
|
+
"# Review by title only.",
|
|
31
|
+
'# Mark relevant items as "include" with a comment explaining the reason.',
|
|
32
|
+
'# Leave everything else as "uncertain".',
|
|
33
|
+
""
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
27
36
|
return [
|
|
28
37
|
schemaLine,
|
|
29
38
|
"# Screening by title only.",
|
|
@@ -49,10 +58,10 @@ function getBasisGuidanceComment(basis) {
|
|
|
49
58
|
].join("\n");
|
|
50
59
|
}
|
|
51
60
|
}
|
|
52
|
-
function getDecisionInlineComment(basis) {
|
|
61
|
+
function getDecisionInlineComment(basis, mode = "screening") {
|
|
53
62
|
switch (basis) {
|
|
54
63
|
case "title":
|
|
55
|
-
return "# exclude / uncertain";
|
|
64
|
+
return mode === "picking" ? "# include / uncertain" : "# exclude / uncertain";
|
|
56
65
|
case "abstract":
|
|
57
66
|
case "fulltext":
|
|
58
67
|
return "# include / exclude / uncertain";
|
|
@@ -135,6 +144,7 @@ async function executeReviewExtract(options, sessionsDir) {
|
|
|
135
144
|
const sessionDir = join(sessionsDir, options.sessionId);
|
|
136
145
|
const outputPath = join(sessionDir, "for-review", options.name, "review.yaml");
|
|
137
146
|
const reviewFile = await loadReviewFile(sessionDir);
|
|
147
|
+
const mode = reviewFile.mode ?? "screening";
|
|
138
148
|
const reviewers = reviewFile.reviewers;
|
|
139
149
|
let filtered;
|
|
140
150
|
if (options.filter && options.filter.length > 0) {
|
|
@@ -166,7 +176,7 @@ async function executeReviewExtract(options, sessionsDir) {
|
|
|
166
176
|
articles: paginated.map((article) => buildScreeningArticle(article, options.basis))
|
|
167
177
|
};
|
|
168
178
|
const yamlContent = stringify(outputFile, { lineWidth: 0 });
|
|
169
|
-
const decisionComment = getDecisionInlineComment(options.basis);
|
|
179
|
+
const decisionComment = getDecisionInlineComment(options.basis, mode);
|
|
170
180
|
let yamlWithComments = yamlContent.replace(
|
|
171
181
|
/^(\s*-?\s*)decision: uncertain$/gm,
|
|
172
182
|
`$1decision: uncertain ${decisionComment}`
|
|
@@ -175,7 +185,7 @@ async function executeReviewExtract(options, sessionsDir) {
|
|
|
175
185
|
/^(\s*)comment: ""$/gm,
|
|
176
186
|
'$1comment: "" # reason for decision'
|
|
177
187
|
);
|
|
178
|
-
const guidanceComment = getBasisGuidanceComment(options.basis);
|
|
188
|
+
const guidanceComment = getBasisGuidanceComment(options.basis, mode);
|
|
179
189
|
finalContent = guidanceComment + yamlWithComments;
|
|
180
190
|
} else {
|
|
181
191
|
const outputFile = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"extract.js","sources":["../../../../src/cli/commands/review/extract.ts"],"sourcesContent":["/**\n * review extract command - Extract subset of articles for distributed review\n */\n\nimport { join, dirname } from 'node:path';\nimport { readFile, writeFile, mkdir, copyFile, access } from 'node:fs/promises';\nimport { parse as parseYaml, stringify as stringifyYaml } from 'yaml';\nimport { classifyStatus, type ReviewFile, type ArticleEntry, type ReviewStatus, type ReviewBasis } from './types.js';\n\nexport type SortOption = 'year' | 'title' | 'random' | 'none';\n\nexport interface ReviewExtractOptions {\n sessionId: string;\n filter?: ReviewStatus[];\n sort?: SortOption;\n seed?: number;\n limit?: number;\n offset?: number;\n /** Basis for the review (title, abstract, fulltext). When specified, outputs screening format. */\n basis?: ReviewBasis;\n /** Reviewer identifier (e.g., \"ai:claude\"). Required for all extract modes. */\n reviewer?: string;\n /** Name for the review subset (output goes to for-review/<name>/review.yaml) */\n name: string;\n /** When true, outputs final decision format with reviewHistory and finalDecision fields. */\n finalize?: boolean;\n}\n\n\nexport interface ReviewExtractResult {\n outputPath: string;\n extractedCount: number;\n totalMatching: number;\n}\n\n/**\n * Load review file from session directory\n */\nasync function loadReviewFile(sessionDir: string): Promise<ReviewFile> {\n const reviewsPath = join(sessionDir, '.internal', 'reviews.yaml');\n const content = await readFile(reviewsPath, 'utf-8');\n return parseYaml(content) as ReviewFile;\n}\n\n/**\n * Seeded random number generator (Fisher-Yates shuffle with LCG)\n */\nfunction seededShuffle<T>(array: T[], seed: number): T[] {\n const result = [...array];\n let currentSeed = seed;\n\n // Linear congruential generator\n function random(): number {\n currentSeed = (currentSeed * 1664525 + 1013904223) % 4294967296;\n return currentSeed / 4294967296;\n }\n\n // Fisher-Yates shuffle\n for (let i = result.length - 1; i > 0; i--) {\n const j = Math.floor(random() * (i + 1));\n [result[i], result[j]] = [result[j]!, result[i]!];\n }\n\n return result;\n}\n\n/**\n * Get the best identifier for an article (doi > pmid > scopusId > arxivId > ericId > title)\n */\nfunction getArticleId(article: ArticleEntry): string {\n if (article.doi) return article.doi;\n if (article.pmid) return article.pmid;\n if (article.scopusId) return article.scopusId;\n if (article.arxivId) return article.arxivId;\n if (article.ericId) return article.ericId;\n return article.title;\n}\n\nfunction getBasisGuidanceComment(basis: ReviewBasis): string {\n const schemaLine = '# yaml-language-server: $schema=./review.schema.json';\n switch (basis) {\n case 'title':\n return [\n schemaLine,\n '# Screening by title only.',\n '# Mark clearly irrelevant items as \"exclude\" with a comment explaining the reason.',\n '# Leave everything else as \"uncertain\".',\n '',\n ].join('\\n');\n case 'abstract':\n return [\n schemaLine,\n '# Screening by title and abstract.',\n '# You should be able to decide \"include\" or \"exclude\" for most items at this stage.',\n '# Mark remaining ambiguous items as \"uncertain\" with a comment explaining why.',\n '',\n ].join('\\n');\n case 'fulltext':\n return [\n schemaLine,\n '# Screening by full text. This is the final decision stage.',\n '# Decide \"include\" or \"exclude\" for each item.',\n '# Use \"uncertain\" only when absolutely unavoidable, with a comment explaining why.',\n '',\n ].join('\\n');\n }\n}\n\nfunction getDecisionInlineComment(basis: ReviewBasis): string {\n switch (basis) {\n case 'title':\n return '# exclude / uncertain';\n case 'abstract':\n case 'fulltext':\n return '# include / exclude / uncertain';\n }\n}\n\n/** Build a screening article for --basis mode: only include fields relevant to the basis */\nfunction buildScreeningArticle(article: ArticleEntry, basis: ReviewBasis): ArticleEntry {\n // Start with identifiers\n const result: ArticleEntry = { title: article.title, reviews: [] };\n if (article.doi) result.doi = article.doi;\n if (article.pmid) result.pmid = article.pmid;\n if (article.scopusId) result.scopusId = article.scopusId;\n if (article.arxivId) result.arxivId = article.arxivId;\n if (article.ericId) result.ericId = article.ericId;\n\n // Include abstract for abstract and fulltext basis\n if ((basis === 'abstract' || basis === 'fulltext') && article.abstract) {\n result.abstract = article.abstract;\n }\n\n // Include fulltext ref for fulltext basis\n if (basis === 'fulltext' && article.fulltext) {\n result.fulltext = article.fulltext;\n }\n\n // Pre-populate reviews (reviewer omitted; filled from top-level field on merge)\n result.reviews = [{ decision: 'uncertain' as const, comment: '' } as ArticleEntry['reviews'][0]];\n\n return result;\n}\n\n/** Build a finalize article with reviewHistory and finalDecision, optionally scoped by basis */\nfunction buildFinalizeArticle(article: ArticleEntry, basis?: ReviewBasis): ArticleEntry {\n const result: ArticleEntry = { title: article.title, reviews: [] };\n\n // Always include identifiers\n if (article.doi) result.doi = article.doi;\n if (article.pmid) result.pmid = article.pmid;\n if (article.scopusId) result.scopusId = article.scopusId;\n if (article.arxivId) result.arxivId = article.arxivId;\n if (article.ericId) result.ericId = article.ericId;\n\n // Always include bibliographic metadata\n if (article.authors) result.authors = article.authors;\n if (article.year) result.year = article.year;\n\n // Scope content by basis (or include all if no basis)\n if (!basis || basis === 'abstract' || basis === 'fulltext') {\n if (article.abstract) result.abstract = article.abstract;\n }\n if (!basis || basis === 'fulltext') {\n if (article.fulltext) result.fulltext = article.fulltext;\n }\n\n // Add reviewHistory (existing reviews, read-only)\n result.reviewHistory = article.reviews ?? [];\n\n // Empty reviews for new reviews\n result.reviews = [];\n\n // Null finalDecision as placeholder\n result.finalDecision = null;\n\n return result;\n}\n\nfunction getFinalDecisionGuidanceComment(): string {\n return [\n '# yaml-language-server: $schema=./review.schema.json',\n '# Final decision file: set finalDecision on each article',\n '# Valid decisions: include / exclude / null',\n '',\n ].join('\\n');\n}\n\n/**\n * Sort articles based on sort option\n */\nfunction sortArticles(articles: ArticleEntry[], sort: SortOption, seed?: number): ArticleEntry[] {\n switch (sort) {\n case 'year':\n return [...articles].sort((a, b) => {\n const yearA = a.year ?? '';\n const yearB = b.year ?? '';\n return yearA.localeCompare(yearB);\n });\n case 'title':\n return [...articles].sort((a, b) => a.title.localeCompare(b.title));\n case 'random':\n return seededShuffle(articles, seed ?? Date.now());\n case 'none':\n default:\n return articles;\n }\n}\n\n/**\n * Validate the name parameter for extract\n */\nexport function validateName(name: string): void {\n if (!name || name.trim() === '') {\n throw new Error('--name must not be empty');\n }\n if (name.includes('/') || name.includes('\\\\')) {\n throw new Error(`--name must not contain path separators: \"${name}\"`);\n }\n if (name.includes('..')) {\n throw new Error(`--name must not contain \"..\": \"${name}\"`);\n }\n}\n\n/**\n * Execute review extract command\n */\nexport async function executeReviewExtract(\n options: ReviewExtractOptions,\n sessionsDir: string\n): Promise<ReviewExtractResult> {\n validateName(options.name);\n\n const sessionDir = join(sessionsDir, options.sessionId);\n const outputPath = join(sessionDir, 'for-review', options.name, 'review.yaml');\n const reviewFile = await loadReviewFile(sessionDir);\n\n // Filter articles by status\n const reviewers = reviewFile.reviewers;\n let filtered: ArticleEntry[];\n if (options.filter && options.filter.length > 0) {\n filtered = reviewFile.articles.filter((article) => {\n const status = classifyStatus(article, reviewers);\n return options.filter!.includes(status);\n });\n } else {\n filtered = [...reviewFile.articles];\n }\n\n const totalMatching = filtered.length;\n\n // Sort articles\n const sorted = sortArticles(filtered, options.sort ?? 'none', options.seed);\n\n // Apply pagination\n let paginated = sorted;\n if (options.offset !== undefined && options.offset > 0) {\n paginated = paginated.slice(options.offset);\n }\n if (options.limit !== undefined && options.limit > 0) {\n paginated = paginated.slice(0, options.limit);\n }\n\n if (!options.reviewer) {\n throw new Error('--reviewer is required for review file extract');\n }\n\n let finalContent: string;\n\n if (options.basis && !options.finalize) {\n // Screening mode: basis-scoped content with pre-populated reviews\n const outputFile: ReviewFile = {\n sessionId: options.sessionId,\n basis: options.basis,\n reviewer: options.reviewer,\n articles: paginated.map((article) => buildScreeningArticle(article, options.basis!)),\n };\n\n const yamlContent = stringifyYaml(outputFile, { lineWidth: 0 });\n\n // Add decision inline comments\n const decisionComment = getDecisionInlineComment(options.basis);\n let yamlWithComments = yamlContent.replace(\n /^(\\s*-?\\s*)decision: uncertain$/gm,\n `$1decision: uncertain ${decisionComment}`\n );\n\n // Add comment field inline guidance\n yamlWithComments = yamlWithComments.replace(\n /^(\\s*)comment: \"\"$/gm,\n '$1comment: \"\" # reason for decision'\n );\n\n const guidanceComment = getBasisGuidanceComment(options.basis);\n finalContent = guidanceComment + yamlWithComments;\n } else {\n // Final decision mode: --finalize, or no --basis (backward compat)\n const outputFile: ReviewFile = {\n sessionId: options.sessionId,\n reviewer: options.reviewer,\n articles: paginated.map((article) => buildFinalizeArticle(article, options.basis)),\n };\n\n const yamlContent = stringifyYaml(outputFile, { lineWidth: 0 });\n\n // Replace finalDecision: null with a commented placeholder for user guidance\n let yamlWithComments = yamlContent.replace(\n /^(\\s*)finalDecision: null$/gm,\n '$1finalDecision: # include / exclude'\n );\n\n // Add reviews array inline guidance\n yamlWithComments = yamlWithComments.replace(\n /^(\\s*)reviews: \\[\\]$/gm,\n '$1reviews: [] # add new reviews here'\n );\n\n const guidanceComment = getFinalDecisionGuidanceComment();\n finalContent = guidanceComment + yamlWithComments;\n }\n\n // Ensure output directory exists\n const outputDir = dirname(outputPath);\n await mkdir(outputDir, { recursive: true });\n\n // Write output YAML\n await writeFile(outputPath, finalContent, 'utf-8');\n\n // Copy schema file to output directory if it exists\n const schemaSourcePath = join(sessionDir, '.internal', 'review.schema.json');\n const schemaDestPath = join(outputDir, 'review.schema.json');\n\n try {\n await access(schemaSourcePath);\n await copyFile(schemaSourcePath, schemaDestPath);\n } catch {\n // Schema file doesn't exist, skip copying\n }\n\n return {\n outputPath,\n extractedCount: paginated.length,\n totalMatching,\n };\n}\n"],"names":["parseYaml","stringifyYaml"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"extract.js","sources":["../../../../src/cli/commands/review/extract.ts"],"sourcesContent":["/**\n * review extract command - Extract subset of articles for distributed review\n */\n\nimport { join, dirname } from 'node:path';\nimport { readFile, writeFile, mkdir, copyFile, access } from 'node:fs/promises';\nimport { parse as parseYaml, stringify as stringifyYaml } from 'yaml';\nimport { classifyStatus, type ReviewFile, type ArticleEntry, type ReviewStatus, type ReviewBasis, type ReviewMode } from './types.js';\n\nexport type SortOption = 'year' | 'title' | 'random' | 'none';\n\nexport interface ReviewExtractOptions {\n sessionId: string;\n filter?: ReviewStatus[];\n sort?: SortOption;\n seed?: number;\n limit?: number;\n offset?: number;\n /** Basis for the review (title, abstract, fulltext). When specified, outputs screening format. */\n basis?: ReviewBasis;\n /** Reviewer identifier (e.g., \"ai:claude\"). Required for all extract modes. */\n reviewer?: string;\n /** Name for the review subset (output goes to for-review/<name>/review.yaml) */\n name: string;\n /** When true, outputs final decision format with reviewHistory and finalDecision fields. */\n finalize?: boolean;\n}\n\n\nexport interface ReviewExtractResult {\n outputPath: string;\n extractedCount: number;\n totalMatching: number;\n}\n\n/**\n * Load review file from session directory\n */\nasync function loadReviewFile(sessionDir: string): Promise<ReviewFile> {\n const reviewsPath = join(sessionDir, '.internal', 'reviews.yaml');\n const content = await readFile(reviewsPath, 'utf-8');\n return parseYaml(content) as ReviewFile;\n}\n\n/**\n * Seeded random number generator (Fisher-Yates shuffle with LCG)\n */\nfunction seededShuffle<T>(array: T[], seed: number): T[] {\n const result = [...array];\n let currentSeed = seed;\n\n // Linear congruential generator\n function random(): number {\n currentSeed = (currentSeed * 1664525 + 1013904223) % 4294967296;\n return currentSeed / 4294967296;\n }\n\n // Fisher-Yates shuffle\n for (let i = result.length - 1; i > 0; i--) {\n const j = Math.floor(random() * (i + 1));\n [result[i], result[j]] = [result[j]!, result[i]!];\n }\n\n return result;\n}\n\n/**\n * Get the best identifier for an article (doi > pmid > scopusId > arxivId > ericId > title)\n */\nfunction getArticleId(article: ArticleEntry): string {\n if (article.doi) return article.doi;\n if (article.pmid) return article.pmid;\n if (article.scopusId) return article.scopusId;\n if (article.arxivId) return article.arxivId;\n if (article.ericId) return article.ericId;\n return article.title;\n}\n\nfunction getBasisGuidanceComment(basis: ReviewBasis, mode: ReviewMode = 'screening'): string {\n const schemaLine = '# yaml-language-server: $schema=./review.schema.json';\n switch (basis) {\n case 'title':\n if (mode === 'picking') {\n return [\n schemaLine,\n '# Review by title only.',\n '# Mark relevant items as \"include\" with a comment explaining the reason.',\n '# Leave everything else as \"uncertain\".',\n '',\n ].join('\\n');\n }\n return [\n schemaLine,\n '# Screening by title only.',\n '# Mark clearly irrelevant items as \"exclude\" with a comment explaining the reason.',\n '# Leave everything else as \"uncertain\".',\n '',\n ].join('\\n');\n case 'abstract':\n return [\n schemaLine,\n '# Screening by title and abstract.',\n '# You should be able to decide \"include\" or \"exclude\" for most items at this stage.',\n '# Mark remaining ambiguous items as \"uncertain\" with a comment explaining why.',\n '',\n ].join('\\n');\n case 'fulltext':\n return [\n schemaLine,\n '# Screening by full text. This is the final decision stage.',\n '# Decide \"include\" or \"exclude\" for each item.',\n '# Use \"uncertain\" only when absolutely unavoidable, with a comment explaining why.',\n '',\n ].join('\\n');\n }\n}\n\nfunction getDecisionInlineComment(basis: ReviewBasis, mode: ReviewMode = 'screening'): string {\n switch (basis) {\n case 'title':\n return mode === 'picking' ? '# include / uncertain' : '# exclude / uncertain';\n case 'abstract':\n case 'fulltext':\n return '# include / exclude / uncertain';\n }\n}\n\n/** Build a screening article for --basis mode: only include fields relevant to the basis */\nfunction buildScreeningArticle(article: ArticleEntry, basis: ReviewBasis): ArticleEntry {\n // Start with identifiers\n const result: ArticleEntry = { title: article.title, reviews: [] };\n if (article.doi) result.doi = article.doi;\n if (article.pmid) result.pmid = article.pmid;\n if (article.scopusId) result.scopusId = article.scopusId;\n if (article.arxivId) result.arxivId = article.arxivId;\n if (article.ericId) result.ericId = article.ericId;\n\n // Include abstract for abstract and fulltext basis\n if ((basis === 'abstract' || basis === 'fulltext') && article.abstract) {\n result.abstract = article.abstract;\n }\n\n // Include fulltext ref for fulltext basis\n if (basis === 'fulltext' && article.fulltext) {\n result.fulltext = article.fulltext;\n }\n\n // Pre-populate reviews (reviewer omitted; filled from top-level field on merge)\n result.reviews = [{ decision: 'uncertain' as const, comment: '' } as ArticleEntry['reviews'][0]];\n\n return result;\n}\n\n/** Build a finalize article with reviewHistory and finalDecision, optionally scoped by basis */\nfunction buildFinalizeArticle(article: ArticleEntry, basis?: ReviewBasis): ArticleEntry {\n const result: ArticleEntry = { title: article.title, reviews: [] };\n\n // Always include identifiers\n if (article.doi) result.doi = article.doi;\n if (article.pmid) result.pmid = article.pmid;\n if (article.scopusId) result.scopusId = article.scopusId;\n if (article.arxivId) result.arxivId = article.arxivId;\n if (article.ericId) result.ericId = article.ericId;\n\n // Always include bibliographic metadata\n if (article.authors) result.authors = article.authors;\n if (article.year) result.year = article.year;\n\n // Scope content by basis (or include all if no basis)\n if (!basis || basis === 'abstract' || basis === 'fulltext') {\n if (article.abstract) result.abstract = article.abstract;\n }\n if (!basis || basis === 'fulltext') {\n if (article.fulltext) result.fulltext = article.fulltext;\n }\n\n // Add reviewHistory (existing reviews, read-only)\n result.reviewHistory = article.reviews ?? [];\n\n // Empty reviews for new reviews\n result.reviews = [];\n\n // Null finalDecision as placeholder\n result.finalDecision = null;\n\n return result;\n}\n\nfunction getFinalDecisionGuidanceComment(): string {\n return [\n '# yaml-language-server: $schema=./review.schema.json',\n '# Final decision file: set finalDecision on each article',\n '# Valid decisions: include / exclude / null',\n '',\n ].join('\\n');\n}\n\n/**\n * Sort articles based on sort option\n */\nfunction sortArticles(articles: ArticleEntry[], sort: SortOption, seed?: number): ArticleEntry[] {\n switch (sort) {\n case 'year':\n return [...articles].sort((a, b) => {\n const yearA = a.year ?? '';\n const yearB = b.year ?? '';\n return yearA.localeCompare(yearB);\n });\n case 'title':\n return [...articles].sort((a, b) => a.title.localeCompare(b.title));\n case 'random':\n return seededShuffle(articles, seed ?? Date.now());\n case 'none':\n default:\n return articles;\n }\n}\n\n/**\n * Validate the name parameter for extract\n */\nexport function validateName(name: string): void {\n if (!name || name.trim() === '') {\n throw new Error('--name must not be empty');\n }\n if (name.includes('/') || name.includes('\\\\')) {\n throw new Error(`--name must not contain path separators: \"${name}\"`);\n }\n if (name.includes('..')) {\n throw new Error(`--name must not contain \"..\": \"${name}\"`);\n }\n}\n\n/**\n * Execute review extract command\n */\nexport async function executeReviewExtract(\n options: ReviewExtractOptions,\n sessionsDir: string\n): Promise<ReviewExtractResult> {\n validateName(options.name);\n\n const sessionDir = join(sessionsDir, options.sessionId);\n const outputPath = join(sessionDir, 'for-review', options.name, 'review.yaml');\n const reviewFile = await loadReviewFile(sessionDir);\n\n // Load mode from master file (default: screening)\n const mode: ReviewMode = reviewFile.mode ?? 'screening';\n\n // Filter articles by status\n const reviewers = reviewFile.reviewers;\n let filtered: ArticleEntry[];\n if (options.filter && options.filter.length > 0) {\n filtered = reviewFile.articles.filter((article) => {\n const status = classifyStatus(article, reviewers);\n return options.filter!.includes(status);\n });\n } else {\n filtered = [...reviewFile.articles];\n }\n\n const totalMatching = filtered.length;\n\n // Sort articles\n const sorted = sortArticles(filtered, options.sort ?? 'none', options.seed);\n\n // Apply pagination\n let paginated = sorted;\n if (options.offset !== undefined && options.offset > 0) {\n paginated = paginated.slice(options.offset);\n }\n if (options.limit !== undefined && options.limit > 0) {\n paginated = paginated.slice(0, options.limit);\n }\n\n if (!options.reviewer) {\n throw new Error('--reviewer is required for review file extract');\n }\n\n let finalContent: string;\n\n if (options.basis && !options.finalize) {\n // Screening mode: basis-scoped content with pre-populated reviews\n const outputFile: ReviewFile = {\n sessionId: options.sessionId,\n basis: options.basis,\n reviewer: options.reviewer,\n articles: paginated.map((article) => buildScreeningArticle(article, options.basis!)),\n };\n\n const yamlContent = stringifyYaml(outputFile, { lineWidth: 0 });\n\n // Add decision inline comments\n const decisionComment = getDecisionInlineComment(options.basis, mode);\n let yamlWithComments = yamlContent.replace(\n /^(\\s*-?\\s*)decision: uncertain$/gm,\n `$1decision: uncertain ${decisionComment}`\n );\n\n // Add comment field inline guidance\n yamlWithComments = yamlWithComments.replace(\n /^(\\s*)comment: \"\"$/gm,\n '$1comment: \"\" # reason for decision'\n );\n\n const guidanceComment = getBasisGuidanceComment(options.basis, mode);\n finalContent = guidanceComment + yamlWithComments;\n } else {\n // Final decision mode: --finalize, or no --basis (backward compat)\n const outputFile: ReviewFile = {\n sessionId: options.sessionId,\n reviewer: options.reviewer,\n articles: paginated.map((article) => buildFinalizeArticle(article, options.basis)),\n };\n\n const yamlContent = stringifyYaml(outputFile, { lineWidth: 0 });\n\n // Replace finalDecision: null with a commented placeholder for user guidance\n let yamlWithComments = yamlContent.replace(\n /^(\\s*)finalDecision: null$/gm,\n '$1finalDecision: # include / exclude'\n );\n\n // Add reviews array inline guidance\n yamlWithComments = yamlWithComments.replace(\n /^(\\s*)reviews: \\[\\]$/gm,\n '$1reviews: [] # add new reviews here'\n );\n\n const guidanceComment = getFinalDecisionGuidanceComment();\n finalContent = guidanceComment + yamlWithComments;\n }\n\n // Ensure output directory exists\n const outputDir = dirname(outputPath);\n await mkdir(outputDir, { recursive: true });\n\n // Write output YAML\n await writeFile(outputPath, finalContent, 'utf-8');\n\n // Copy schema file to output directory if it exists\n const schemaSourcePath = join(sessionDir, '.internal', 'review.schema.json');\n const schemaDestPath = join(outputDir, 'review.schema.json');\n\n try {\n await access(schemaSourcePath);\n await copyFile(schemaSourcePath, schemaDestPath);\n } catch {\n // Schema file doesn't exist, skip copying\n }\n\n return {\n outputPath,\n extractedCount: paginated.length,\n totalMatching,\n };\n}\n"],"names":["parseYaml","stringifyYaml"],"mappings":"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"}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
* review init command - Generate reviews.yaml from session results
|
|
3
|
-
*/
|
|
1
|
+
import { ReviewMode } from './types.js';
|
|
4
2
|
export interface ReviewInitOptions {
|
|
5
3
|
sessionId: string;
|
|
4
|
+
mode?: ReviewMode;
|
|
6
5
|
force?: boolean;
|
|
7
6
|
}
|
|
8
7
|
export interface ReviewInitResult {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/init.ts"],"names":[],"mappings":"AAAA;;GAEG;
|
|
1
|
+
{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/init.ts"],"names":[],"mappings":"AAAA;;GAEG;AAUH,OAAO,KAAK,EAA4B,UAAU,EAAgB,MAAM,YAAY,CAAC;AAErF,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAyDD;;GAEG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,iBAAiB,EAC1B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,gBAAgB,CAAC,CA8E3B"}
|
|
@@ -67,6 +67,7 @@ async function executeReviewInit(options, sessionsDir) {
|
|
|
67
67
|
const articleEntries = dedupedArticles.map(articleToEntry);
|
|
68
68
|
const reviewFile = {
|
|
69
69
|
sessionId: options.sessionId,
|
|
70
|
+
...options.mode && { mode: options.mode },
|
|
70
71
|
articles: articleEntries
|
|
71
72
|
};
|
|
72
73
|
const yamlContent = stringify(reviewFile, {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"init.js","sources":["../../../../src/cli/commands/review/init.ts"],"sourcesContent":["/**\n * review init command - Generate reviews.yaml from session results\n */\n\nimport { join } from 'node:path';\nimport { writeFile, mkdir, access } from 'node:fs/promises';\nimport { stringify as stringifyYaml } from 'yaml';\nimport type { Article, Author, ProviderName } from '../../../providers/base/types.js';\nimport { loadSession } from '../../../session/manager.js';\nimport { loadResults } from '../../../session/results-io.js';\nimport { deduplicateForReview } from './dedup.js';\nimport { generateReviewJSONSchema } from './schema.js';\nimport type { ArticleEntry, ReviewFile, MergedSource } from './types.js';\n\nexport interface ReviewInitOptions {\n sessionId: string;\n force?: boolean;\n}\n\nexport interface ReviewInitResult {\n reviewsPath: string;\n articleCount: number;\n duplicatesRemoved: number;\n}\n\n/**\n * Format authors array to string\n */\nfunction formatAuthors(authors: Author[]): string {\n return authors\n .map((a) => {\n const parts: string[] = [];\n if (a.family) parts.push(a.family);\n if (a.given) parts.push(a.given.charAt(0));\n return parts.join(' ');\n })\n .join(', ');\n}\n\n/**\n * Extract year from publication date\n */\nfunction extractYear(publicationDate?: string): string | undefined {\n if (!publicationDate) return undefined;\n const year = publicationDate.substring(0, 4);\n return /^\\d{4}$/.test(year) ? year : undefined;\n}\n\n/**\n * Convert Article to ArticleEntry for review file\n */\nfunction articleToEntry(article: Article & { mergedFrom?: MergedSource[] }): ArticleEntry {\n const entry: ArticleEntry = {\n title: article.title,\n reviews: [],\n };\n\n // Add identifiers\n if (article.doi) entry.doi = article.doi;\n if (article.pmid) entry.pmid = article.pmid;\n if (article.scopusId) entry.scopusId = article.scopusId;\n if (article.arxivId) entry.arxivId = article.arxivId;\n if (article.ericId) entry.ericId = article.ericId;\n\n // Add bibliographic info\n if (article.authors && article.authors.length > 0) {\n entry.authors = formatAuthors(article.authors);\n }\n const year = extractYear(article.publicationDate);\n if (year) entry.year = year;\n if (article.abstract) entry.abstract = article.abstract;\n\n // Add deduplication tracking\n if (article.mergedFrom && article.mergedFrom.length > 0) {\n entry.mergedFrom = article.mergedFrom;\n }\n\n return entry;\n}\n\n/**\n * Execute review init command\n */\nexport async function executeReviewInit(\n options: ReviewInitOptions,\n sessionsDir: string\n): Promise<ReviewInitResult> {\n const sessionDir = join(sessionsDir, options.sessionId);\n\n // Load session file\n const session = await loadSession(options.sessionId, sessionsDir);\n\n // Check if .internal/reviews.yaml already exists\n const internalDir = join(sessionDir, '.internal');\n const reviewsPath = join(internalDir, 'reviews.yaml');\n try {\n await access(reviewsPath);\n if (!options.force) {\n throw new Error(`reviews.yaml already exists. Use --force to overwrite.`);\n }\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {\n throw err;\n }\n }\n\n // Create .internal/ directory\n await mkdir(internalDir, { recursive: true });\n\n // Load all results from session\n const allArticles: Article[] = [];\n const providers = Object.keys(session.databases) as ProviderName[];\n\n for (const provider of providers) {\n const dbStatus = session.databases[provider];\n if (!dbStatus) continue;\n const articles = await loadResults(sessionDir, provider);\n allArticles.push(...articles);\n }\n\n // Deduplicate with mergedFrom tracking\n const { articles: dedupedArticles, duplicatesRemoved } = deduplicateForReview(allArticles);\n\n // Convert to ArticleEntry format\n const articleEntries = dedupedArticles.map(articleToEntry);\n\n // Build review file\n const reviewFile: ReviewFile = {\n sessionId: options.sessionId,\n articles: articleEntries,\n };\n\n // Generate YAML with schema reference comment\n const yamlContent = stringifyYaml(reviewFile, {\n lineWidth: 0, // Disable line wrapping\n });\n\n // Add schema reference comment at top (local copy alongside reviews.yaml)\n const schemaComment = `# yaml-language-server: $schema=./review.schema.json\\n`;\n\n // Replace empty reviews arrays with commented example\n const reviewsExample = `reviews:\n # - reviewer: human:your-name\n # decision: include # include / exclude / uncertain\n # comment: reason`;\n const finalContent = schemaComment + yamlContent.replace(\n /reviews: \\[\\]/g,\n reviewsExample\n );\n\n // Write reviews.yaml\n await writeFile(reviewsPath, finalContent, 'utf-8');\n\n // Generate and write schema file to .internal/ alongside reviews.yaml\n const schemaDestPath = join(internalDir, 'review.schema.json');\n const jsonSchema = generateReviewJSONSchema();\n await writeFile(schemaDestPath, JSON.stringify(jsonSchema, null, 2) + '\\n', 'utf-8');\n\n return {\n reviewsPath,\n articleCount: articleEntries.length,\n duplicatesRemoved,\n };\n}\n"],"names":["stringifyYaml"],"mappings":";;;;;;;
|
|
1
|
+
{"version":3,"file":"init.js","sources":["../../../../src/cli/commands/review/init.ts"],"sourcesContent":["/**\n * review init command - Generate reviews.yaml from session results\n */\n\nimport { join } from 'node:path';\nimport { writeFile, mkdir, access } from 'node:fs/promises';\nimport { stringify as stringifyYaml } from 'yaml';\nimport type { Article, Author, ProviderName } from '../../../providers/base/types.js';\nimport { loadSession } from '../../../session/manager.js';\nimport { loadResults } from '../../../session/results-io.js';\nimport { deduplicateForReview } from './dedup.js';\nimport { generateReviewJSONSchema } from './schema.js';\nimport type { ArticleEntry, ReviewFile, ReviewMode, MergedSource } from './types.js';\n\nexport interface ReviewInitOptions {\n sessionId: string;\n mode?: ReviewMode;\n force?: boolean;\n}\n\nexport interface ReviewInitResult {\n reviewsPath: string;\n articleCount: number;\n duplicatesRemoved: number;\n}\n\n/**\n * Format authors array to string\n */\nfunction formatAuthors(authors: Author[]): string {\n return authors\n .map((a) => {\n const parts: string[] = [];\n if (a.family) parts.push(a.family);\n if (a.given) parts.push(a.given.charAt(0));\n return parts.join(' ');\n })\n .join(', ');\n}\n\n/**\n * Extract year from publication date\n */\nfunction extractYear(publicationDate?: string): string | undefined {\n if (!publicationDate) return undefined;\n const year = publicationDate.substring(0, 4);\n return /^\\d{4}$/.test(year) ? year : undefined;\n}\n\n/**\n * Convert Article to ArticleEntry for review file\n */\nfunction articleToEntry(article: Article & { mergedFrom?: MergedSource[] }): ArticleEntry {\n const entry: ArticleEntry = {\n title: article.title,\n reviews: [],\n };\n\n // Add identifiers\n if (article.doi) entry.doi = article.doi;\n if (article.pmid) entry.pmid = article.pmid;\n if (article.scopusId) entry.scopusId = article.scopusId;\n if (article.arxivId) entry.arxivId = article.arxivId;\n if (article.ericId) entry.ericId = article.ericId;\n\n // Add bibliographic info\n if (article.authors && article.authors.length > 0) {\n entry.authors = formatAuthors(article.authors);\n }\n const year = extractYear(article.publicationDate);\n if (year) entry.year = year;\n if (article.abstract) entry.abstract = article.abstract;\n\n // Add deduplication tracking\n if (article.mergedFrom && article.mergedFrom.length > 0) {\n entry.mergedFrom = article.mergedFrom;\n }\n\n return entry;\n}\n\n/**\n * Execute review init command\n */\nexport async function executeReviewInit(\n options: ReviewInitOptions,\n sessionsDir: string\n): Promise<ReviewInitResult> {\n const sessionDir = join(sessionsDir, options.sessionId);\n\n // Load session file\n const session = await loadSession(options.sessionId, sessionsDir);\n\n // Check if .internal/reviews.yaml already exists\n const internalDir = join(sessionDir, '.internal');\n const reviewsPath = join(internalDir, 'reviews.yaml');\n try {\n await access(reviewsPath);\n if (!options.force) {\n throw new Error(`reviews.yaml already exists. Use --force to overwrite.`);\n }\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {\n throw err;\n }\n }\n\n // Create .internal/ directory\n await mkdir(internalDir, { recursive: true });\n\n // Load all results from session\n const allArticles: Article[] = [];\n const providers = Object.keys(session.databases) as ProviderName[];\n\n for (const provider of providers) {\n const dbStatus = session.databases[provider];\n if (!dbStatus) continue;\n const articles = await loadResults(sessionDir, provider);\n allArticles.push(...articles);\n }\n\n // Deduplicate with mergedFrom tracking\n const { articles: dedupedArticles, duplicatesRemoved } = deduplicateForReview(allArticles);\n\n // Convert to ArticleEntry format\n const articleEntries = dedupedArticles.map(articleToEntry);\n\n // Build review file\n const reviewFile: ReviewFile = {\n sessionId: options.sessionId,\n ...(options.mode && { mode: options.mode }),\n articles: articleEntries,\n };\n\n // Generate YAML with schema reference comment\n const yamlContent = stringifyYaml(reviewFile, {\n lineWidth: 0, // Disable line wrapping\n });\n\n // Add schema reference comment at top (local copy alongside reviews.yaml)\n const schemaComment = `# yaml-language-server: $schema=./review.schema.json\\n`;\n\n // Replace empty reviews arrays with commented example\n const reviewsExample = `reviews:\n # - reviewer: human:your-name\n # decision: include # include / exclude / uncertain\n # comment: reason`;\n const finalContent = schemaComment + yamlContent.replace(\n /reviews: \\[\\]/g,\n reviewsExample\n );\n\n // Write reviews.yaml\n await writeFile(reviewsPath, finalContent, 'utf-8');\n\n // Generate and write schema file to .internal/ alongside reviews.yaml\n const schemaDestPath = join(internalDir, 'review.schema.json');\n const jsonSchema = generateReviewJSONSchema();\n await writeFile(schemaDestPath, JSON.stringify(jsonSchema, null, 2) + '\\n', 'utf-8');\n\n return {\n reviewsPath,\n articleCount: articleEntries.length,\n duplicatesRemoved,\n };\n}\n"],"names":["stringifyYaml"],"mappings":"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"}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { ReviewStatusResult } from './status.js';
|
|
2
|
+
import { ReviewMode } from './types.js';
|
|
2
3
|
import { Suggestion, SuggestionResult } from '../../suggestions/types.js';
|
|
3
4
|
export interface ReviewNextStepsContext {
|
|
4
5
|
sessionId: string;
|
|
5
6
|
statusResult: ReviewStatusResult;
|
|
7
|
+
/** Review mode: screening (default) or picking */
|
|
8
|
+
mode?: ReviewMode;
|
|
6
9
|
/** Extract name for batch continuation */
|
|
7
10
|
extractName?: string;
|
|
8
11
|
/** Number of articles extracted in current batch */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next-steps.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/next-steps.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"next-steps.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/next-steps.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,KAAK,EAAe,UAAU,EAAE,MAAM,YAAY,CAAC;AAC1D,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AAE/E,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,kBAAkB,CAAC;IACjC,kDAAkD;IAClD,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,0CAA0C;IAC1C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oDAAoD;IACpD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,yCAAyC;IACzC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6BAA6B;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC7B;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,uBAAuB,GAAG,UAAU,GAAG,IAAI,CAU3F;AAaD;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,sBAAsB,GAAG,gBAAgB,GAAG,IAAI,CA4G5F"}
|
|
@@ -15,35 +15,69 @@ function detectNextBasis(reviewers) {
|
|
|
15
15
|
return "fulltext";
|
|
16
16
|
}
|
|
17
17
|
function generateReviewNextSteps(ctx) {
|
|
18
|
-
const { sessionId, statusResult: rs } = ctx;
|
|
18
|
+
const { sessionId, statusResult: rs, mode = "screening" } = ctx;
|
|
19
19
|
if (rs.total === 0) return null;
|
|
20
20
|
const result = { next: [], seeAlso: [] };
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
command: `search-hub review extract --session ${sessionId} --basis title --filter pending --reviewer "<name>" --name title-screening`,
|
|
24
|
-
description: `Extract ${rs.pending} pending articles for title screening`
|
|
25
|
-
});
|
|
26
|
-
} else {
|
|
27
|
-
const agreed = rs.agreedInclude + rs.agreedExclude;
|
|
28
|
-
if (agreed > 0) {
|
|
21
|
+
if (mode === "picking") {
|
|
22
|
+
if (rs.pending > 0) {
|
|
29
23
|
result.next.push({
|
|
30
|
-
command: `search-hub review
|
|
31
|
-
description: `
|
|
24
|
+
command: `search-hub review extract --session ${sessionId} --basis title --filter pending --reviewer "<name>" --name title-picking`,
|
|
25
|
+
description: `Extract ${rs.pending} pending articles for title review`
|
|
32
26
|
});
|
|
33
|
-
} else if (rs.
|
|
34
|
-
const unresolved = rs.divided + rs.allUncertain + rs.incomplete;
|
|
27
|
+
} else if (rs.agreedInclude > 0 || rs.allUncertain > 0) {
|
|
35
28
|
const nextBasis = detectNextBasis(rs.reviewers);
|
|
29
|
+
const parts = [];
|
|
30
|
+
if (rs.agreedInclude > 0) parts.push(`${rs.agreedInclude} picked`);
|
|
31
|
+
if (rs.allUncertain > 0) parts.push(`${rs.allUncertain} uncertain`);
|
|
32
|
+
const description = `${parts.join(" + ")} — confirm at ${nextBasis} level`;
|
|
36
33
|
result.next.push({
|
|
37
|
-
command: `search-hub review extract --session ${sessionId} --filter
|
|
38
|
-
description
|
|
34
|
+
command: `search-hub review extract --session ${sessionId} --filter agreed-include,all-uncertain --basis ${nextBasis} --reviewer "<name>" --name ${nextBasis}-screening`,
|
|
35
|
+
description
|
|
39
36
|
});
|
|
40
|
-
} else
|
|
37
|
+
} else {
|
|
38
|
+
const agreed = rs.agreedInclude + rs.agreedExclude;
|
|
39
|
+
if (agreed > 0) {
|
|
40
|
+
result.next.push({
|
|
41
|
+
command: `search-hub review finalize --session ${sessionId}`,
|
|
42
|
+
description: `Finalize ${agreed} articles with consensus`
|
|
43
|
+
});
|
|
44
|
+
} else if (rs.finalized > 0 && rs.finalized === rs.total) {
|
|
45
|
+
result.next.push({
|
|
46
|
+
command: `search-hub review export --session ${sessionId} --only included`,
|
|
47
|
+
description: `${rs.included} articles ready for export`
|
|
48
|
+
});
|
|
49
|
+
} else {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
if (rs.pending > 0) {
|
|
41
55
|
result.next.push({
|
|
42
|
-
command: `search-hub
|
|
43
|
-
description:
|
|
56
|
+
command: `search-hub review extract --session ${sessionId} --basis title --filter pending --reviewer "<name>" --name title-screening`,
|
|
57
|
+
description: `Extract ${rs.pending} pending articles for title screening`
|
|
44
58
|
});
|
|
45
59
|
} else {
|
|
46
|
-
|
|
60
|
+
const agreed = rs.agreedInclude + rs.agreedExclude;
|
|
61
|
+
if (agreed > 0) {
|
|
62
|
+
result.next.push({
|
|
63
|
+
command: `search-hub review finalize --session ${sessionId}`,
|
|
64
|
+
description: `Finalize ${agreed} articles with consensus`
|
|
65
|
+
});
|
|
66
|
+
} else if (rs.divided > 0 || rs.allUncertain > 0 || rs.incomplete > 0) {
|
|
67
|
+
const unresolved = rs.divided + rs.allUncertain + rs.incomplete;
|
|
68
|
+
const nextBasis = detectNextBasis(rs.reviewers);
|
|
69
|
+
result.next.push({
|
|
70
|
+
command: `search-hub review extract --session ${sessionId} --filter divided,all-uncertain,incomplete --basis ${nextBasis} --reviewer "<name>" --name ${nextBasis}-screening`,
|
|
71
|
+
description: `${unresolved} articles need ${nextBasis}-level review`
|
|
72
|
+
});
|
|
73
|
+
} else if (rs.finalized > 0 && rs.finalized === rs.total) {
|
|
74
|
+
result.next.push({
|
|
75
|
+
command: `search-hub register ${sessionId} --reviewed`,
|
|
76
|
+
description: "Register accepted articles"
|
|
77
|
+
});
|
|
78
|
+
} else {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
47
81
|
}
|
|
48
82
|
}
|
|
49
83
|
if (ctx.limit !== void 0 && ctx.extractedCount !== void 0 && ctx.totalMatching !== void 0) {
|