@ncukondo/search-hub 0.16.0 → 0.18.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/query/assess.d.ts +15 -0
- package/dist/cli/commands/query/assess.d.ts.map +1 -0
- package/dist/cli/commands/query/assess.js +38 -0
- package/dist/cli/commands/query/assess.js.map +1 -0
- package/dist/cli/commands/query/iteration-log.d.ts +58 -0
- package/dist/cli/commands/query/iteration-log.d.ts.map +1 -0
- package/dist/cli/commands/query/iteration-log.js +115 -0
- package/dist/cli/commands/query/iteration-log.js.map +1 -0
- package/dist/cli/commands/query/log.d.ts +9 -0
- package/dist/cli/commands/query/log.d.ts.map +1 -0
- package/dist/cli/commands/query/log.js +64 -0
- package/dist/cli/commands/query/log.js.map +1 -0
- package/dist/cli/commands/review/extract.d.ts.map +1 -1
- package/dist/cli/commands/review/extract.js +10 -2
- package/dist/cli/commands/review/extract.js.map +1 -1
- package/dist/cli/commands/review/finalize.d.ts +2 -0
- package/dist/cli/commands/review/finalize.d.ts.map +1 -1
- package/dist/cli/commands/review/finalize.js +12 -1
- package/dist/cli/commands/review/finalize.js.map +1 -1
- package/dist/cli/commands/review/init.d.ts.map +1 -1
- package/dist/cli/commands/review/init.js +5 -21
- package/dist/cli/commands/review/init.js.map +1 -1
- package/dist/cli/commands/review/schema.d.ts +199 -0
- package/dist/cli/commands/review/schema.d.ts.map +1 -0
- package/dist/cli/commands/review/schema.js +77 -0
- package/dist/cli/commands/review/schema.js.map +1 -0
- package/dist/cli/commands/review/types.d.ts +15 -70
- package/dist/cli/commands/review/types.d.ts.map +1 -1
- package/dist/cli/commands/review/types.js +2 -0
- package/dist/cli/commands/review/types.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +89 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/suggestions/rules.d.ts.map +1 -1
- package/dist/cli/suggestions/rules.js +22 -2
- package/dist/cli/suggestions/rules.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface AssessOptions {
|
|
2
|
+
verdict?: string | undefined;
|
|
3
|
+
precision?: string | undefined;
|
|
4
|
+
comment?: string | undefined;
|
|
5
|
+
}
|
|
6
|
+
export interface AssessResult {
|
|
7
|
+
success: boolean;
|
|
8
|
+
error?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Execute the query assess command.
|
|
12
|
+
* Validates inputs and appends an assessment entry to the log.
|
|
13
|
+
*/
|
|
14
|
+
export declare function executeQueryAssess(queryFile: string, options: AssessOptions): Promise<AssessResult>;
|
|
15
|
+
//# sourceMappingURL=assess.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assess.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/query/assess.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC9B;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,wBAAsB,kBAAkB,CACtC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,YAAY,CAAC,CAqCvB"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { formatTimestamp, appendLogEntry } from "./iteration-log.js";
|
|
4
|
+
async function executeQueryAssess(queryFile, options) {
|
|
5
|
+
if (!options.verdict && !options.precision && !options.comment) {
|
|
6
|
+
return {
|
|
7
|
+
success: false,
|
|
8
|
+
error: "At least one of --verdict, --precision, or --comment is required"
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
await access(queryFile, constants.R_OK);
|
|
13
|
+
} catch {
|
|
14
|
+
return {
|
|
15
|
+
success: false,
|
|
16
|
+
error: `Query file not found: ${queryFile}`
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const entry = {
|
|
20
|
+
date: formatTimestamp(),
|
|
21
|
+
type: "assessment"
|
|
22
|
+
};
|
|
23
|
+
if (options.verdict !== void 0) {
|
|
24
|
+
entry.verdict = options.verdict;
|
|
25
|
+
}
|
|
26
|
+
if (options.precision !== void 0) {
|
|
27
|
+
entry.precision = options.precision;
|
|
28
|
+
}
|
|
29
|
+
if (options.comment !== void 0) {
|
|
30
|
+
entry.comment = options.comment;
|
|
31
|
+
}
|
|
32
|
+
await appendLogEntry(queryFile, entry);
|
|
33
|
+
return { success: true };
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
executeQueryAssess
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=assess.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assess.js","sources":["../../../../src/cli/commands/query/assess.ts"],"sourcesContent":["/**\n * Query Assess Command\n *\n * Records a structured assessment of the current query iteration\n * to the search iteration log.\n */\nimport { access } from 'node:fs/promises';\nimport { constants } from 'node:fs';\nimport {\n appendLogEntry,\n formatTimestamp,\n type AssessmentLogEntry,\n} from './iteration-log.js';\n\nexport interface AssessOptions {\n verdict?: string | undefined;\n precision?: string | undefined;\n comment?: string | undefined;\n}\n\nexport interface AssessResult {\n success: boolean;\n error?: string;\n}\n\n/**\n * Execute the query assess command.\n * Validates inputs and appends an assessment entry to the log.\n */\nexport async function executeQueryAssess(\n queryFile: string,\n options: AssessOptions,\n): Promise<AssessResult> {\n // Validate at least one option is provided\n if (!options.verdict && !options.precision && !options.comment) {\n return {\n success: false,\n error: 'At least one of --verdict, --precision, or --comment is required',\n };\n }\n\n // Validate query file exists\n try {\n await access(queryFile, constants.R_OK);\n } catch {\n return {\n success: false,\n error: `Query file not found: ${queryFile}`,\n };\n }\n\n const entry: AssessmentLogEntry = {\n date: formatTimestamp(),\n type: 'assessment',\n };\n\n if (options.verdict !== undefined) {\n entry.verdict = options.verdict;\n }\n if (options.precision !== undefined) {\n entry.precision = options.precision;\n }\n if (options.comment !== undefined) {\n entry.comment = options.comment;\n }\n\n await appendLogEntry(queryFile, entry);\n\n return { success: true };\n}\n"],"names":[],"mappings":";;;AA6BA,eAAsB,mBACpB,WACA,SACuB;AAEvB,MAAI,CAAC,QAAQ,WAAW,CAAC,QAAQ,aAAa,CAAC,QAAQ,SAAS;AAC9D,WAAO;AAAA,MACL,SAAS;AAAA,MACT,OAAO;AAAA,IAAA;AAAA,EAEX;AAGA,MAAI;AACF,UAAM,OAAO,WAAW,UAAU,IAAI;AAAA,EACxC,QAAQ;AACN,WAAO;AAAA,MACL,SAAS;AAAA,MACT,OAAO,yBAAyB,SAAS;AAAA,IAAA;AAAA,EAE7C;AAEA,QAAM,QAA4B;AAAA,IAChC,MAAM,gBAAA;AAAA,IACN,MAAM;AAAA,EAAA;AAGR,MAAI,QAAQ,YAAY,QAAW;AACjC,UAAM,UAAU,QAAQ;AAAA,EAC1B;AACA,MAAI,QAAQ,cAAc,QAAW;AACnC,UAAM,YAAY,QAAQ;AAAA,EAC5B;AACA,MAAI,QAAQ,YAAY,QAAW;AACjC,UAAM,UAAU,QAAQ;AAAA,EAC1B;AAEA,QAAM,eAAe,WAAW,KAAK;AAErC,SAAO,EAAE,SAAS,KAAA;AACpB;"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CountResult, PreviewResult } from '../search.js';
|
|
2
|
+
export interface CountLogEntry {
|
|
3
|
+
date: string;
|
|
4
|
+
type: 'count';
|
|
5
|
+
query_hash: string;
|
|
6
|
+
counts: Record<string, number>;
|
|
7
|
+
total: number;
|
|
8
|
+
}
|
|
9
|
+
export interface PreviewLogEntry {
|
|
10
|
+
date: string;
|
|
11
|
+
type: 'preview';
|
|
12
|
+
query_hash: string;
|
|
13
|
+
counts: Record<string, number>;
|
|
14
|
+
total: number;
|
|
15
|
+
titles: Record<string, string[]>;
|
|
16
|
+
}
|
|
17
|
+
export interface AssessmentLogEntry {
|
|
18
|
+
date: string;
|
|
19
|
+
type: 'assessment';
|
|
20
|
+
verdict?: string;
|
|
21
|
+
precision?: string;
|
|
22
|
+
comment?: string;
|
|
23
|
+
}
|
|
24
|
+
export type LogEntry = CountLogEntry | PreviewLogEntry | AssessmentLogEntry;
|
|
25
|
+
/**
|
|
26
|
+
* Derive the log file path from a query file path.
|
|
27
|
+
* `{dir}/{basename}.yaml` → `{dir}/{basename}.search-log.yaml`
|
|
28
|
+
*/
|
|
29
|
+
export declare function getLogFilePath(queryFilePath: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Generate a timestamp in "YYYY-MM-DD HH:mm" format.
|
|
32
|
+
*/
|
|
33
|
+
export declare function formatTimestamp(date?: Date): string;
|
|
34
|
+
/**
|
|
35
|
+
* Compute a short hash of the query file content.
|
|
36
|
+
* Uses the same algorithm as session creation (SHA-256, first 8 hex chars).
|
|
37
|
+
*/
|
|
38
|
+
export declare function computeQueryHash(queryContent: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Build a CountLogEntry from count-only results.
|
|
41
|
+
*/
|
|
42
|
+
export declare function buildCountLogEntry(queryHash: string, results: CountResult[]): CountLogEntry;
|
|
43
|
+
/**
|
|
44
|
+
* Build a PreviewLogEntry from preview results.
|
|
45
|
+
* Stores at most `maxTitles` titles per provider to avoid log bloat.
|
|
46
|
+
*/
|
|
47
|
+
export declare function buildPreviewLogEntry(queryHash: string, results: PreviewResult[], maxTitles?: number): PreviewLogEntry;
|
|
48
|
+
/**
|
|
49
|
+
* Read log entries from the iteration log file for a given query file.
|
|
50
|
+
* Returns an empty array when the log file does not exist or is empty.
|
|
51
|
+
*/
|
|
52
|
+
export declare function readLogEntries(queryFilePath: string): Promise<LogEntry[]>;
|
|
53
|
+
/**
|
|
54
|
+
* Append a log entry to the iteration log file.
|
|
55
|
+
* Creates the file with a header comment if it does not exist.
|
|
56
|
+
*/
|
|
57
|
+
export declare function appendLogEntry(queryFilePath: string, entry: LogEntry): Promise<void>;
|
|
58
|
+
//# sourceMappingURL=iteration-log.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"iteration-log.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/query/iteration-log.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAI/D,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,OAAO,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,SAAS,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,YAAY,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,QAAQ,GAAG,aAAa,GAAG,eAAe,GAAG,kBAAkB,CAAC;AAI5E;;;GAGG;AACH,wBAAgB,cAAc,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAK5D;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,IAAI,GAAE,IAAiB,GAAG,MAAM,CAO/D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAE7D;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,WAAW,EAAE,GACrB,aAAa,CAgBf;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,aAAa,EAAE,EACxB,SAAS,SAAI,GACZ,eAAe,CAqBjB;AAmBD;;;GAGG;AACH,wBAAsB,cAAc,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CA2B/E;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAClC,aAAa,EAAE,MAAM,EACrB,KAAK,EAAE,QAAQ,GACd,OAAO,CAAC,IAAI,CAAC,CAoBf"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { writeFile, readFile } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { parse, stringify } from "yaml";
|
|
5
|
+
function getLogFilePath(queryFilePath) {
|
|
6
|
+
const dir = dirname(queryFilePath);
|
|
7
|
+
const base = basename(queryFilePath).replace(/\.ya?ml$/, "");
|
|
8
|
+
const logName = `${base}.search-log.yaml`;
|
|
9
|
+
return dir === "." ? logName : join(dir, logName);
|
|
10
|
+
}
|
|
11
|
+
function formatTimestamp(date = /* @__PURE__ */ new Date()) {
|
|
12
|
+
const year = date.getFullYear();
|
|
13
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
14
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
15
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
16
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
17
|
+
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
18
|
+
}
|
|
19
|
+
function computeQueryHash(queryContent) {
|
|
20
|
+
return createHash("sha256").update(queryContent).digest("hex").slice(0, 8);
|
|
21
|
+
}
|
|
22
|
+
function buildCountLogEntry(queryHash, results) {
|
|
23
|
+
const counts = {};
|
|
24
|
+
let total = 0;
|
|
25
|
+
for (const r of results) {
|
|
26
|
+
if (!r.error) {
|
|
27
|
+
counts[r.provider] = r.count;
|
|
28
|
+
total += r.count;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
date: formatTimestamp(),
|
|
33
|
+
type: "count",
|
|
34
|
+
query_hash: queryHash,
|
|
35
|
+
counts,
|
|
36
|
+
total
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function buildPreviewLogEntry(queryHash, results, maxTitles = 5) {
|
|
40
|
+
const counts = {};
|
|
41
|
+
const titles = {};
|
|
42
|
+
let total = 0;
|
|
43
|
+
for (const r of results) {
|
|
44
|
+
if (!r.error) {
|
|
45
|
+
counts[r.provider] = r.count;
|
|
46
|
+
total += r.count;
|
|
47
|
+
if (r.titles.length > 0) {
|
|
48
|
+
titles[r.provider] = r.titles.slice(0, maxTitles);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
date: formatTimestamp(),
|
|
54
|
+
type: "preview",
|
|
55
|
+
query_hash: queryHash,
|
|
56
|
+
counts,
|
|
57
|
+
total,
|
|
58
|
+
titles
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
async function readRawLog(logPath) {
|
|
62
|
+
try {
|
|
63
|
+
return await readFile(logPath, "utf-8");
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error.code === "ENOENT") {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function readLogEntries(queryFilePath) {
|
|
72
|
+
const logPath = getLogFilePath(queryFilePath);
|
|
73
|
+
const content = await readRawLog(logPath);
|
|
74
|
+
if (content === null || !content.trim()) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const parsed = parse(content);
|
|
78
|
+
if (parsed === null || parsed === void 0) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(parsed)) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
return parsed.filter(
|
|
85
|
+
(entry) => entry !== null && typeof entry === "object" && typeof entry.type === "string" && ["count", "preview", "assessment"].includes(entry.type)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
async function appendLogEntry(queryFilePath, entry) {
|
|
89
|
+
const logPath = getLogFilePath(queryFilePath);
|
|
90
|
+
const queryBasename = basename(queryFilePath);
|
|
91
|
+
const existing = await readRawLog(logPath);
|
|
92
|
+
const entryYaml = stringify([entry], { lineWidth: 0 }).trimEnd();
|
|
93
|
+
let content;
|
|
94
|
+
if (existing === null) {
|
|
95
|
+
const header = `# Search iteration log for ${queryBasename}
|
|
96
|
+
# Auto-generated by search-hub. You can also edit this file manually.
|
|
97
|
+
|
|
98
|
+
`;
|
|
99
|
+
content = header + entryYaml + "\n";
|
|
100
|
+
} else {
|
|
101
|
+
const trimmed = existing.trimEnd();
|
|
102
|
+
content = trimmed + "\n\n" + entryYaml + "\n";
|
|
103
|
+
}
|
|
104
|
+
await writeFile(logPath, content, "utf-8");
|
|
105
|
+
}
|
|
106
|
+
export {
|
|
107
|
+
appendLogEntry,
|
|
108
|
+
buildCountLogEntry,
|
|
109
|
+
buildPreviewLogEntry,
|
|
110
|
+
computeQueryHash,
|
|
111
|
+
formatTimestamp,
|
|
112
|
+
getLogFilePath,
|
|
113
|
+
readLogEntries
|
|
114
|
+
};
|
|
115
|
+
//# sourceMappingURL=iteration-log.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"iteration-log.js","sources":["../../../../src/cli/commands/query/iteration-log.ts"],"sourcesContent":["/**\n * Query iteration log I/O.\n *\n * Reads and appends entries to a search iteration log file\n * that lives alongside the query YAML file.\n */\nimport { readFile, writeFile } from 'node:fs/promises';\nimport { createHash } from 'node:crypto';\nimport { basename, dirname, join } from 'node:path';\nimport { parse, stringify } from 'yaml';\nimport type { CountResult, PreviewResult } from '../search.js';\n\n// ── Types ───────────────────────────────────────────────────────────\n\nexport interface CountLogEntry {\n date: string;\n type: 'count';\n query_hash: string;\n counts: Record<string, number>;\n total: number;\n}\n\nexport interface PreviewLogEntry {\n date: string;\n type: 'preview';\n query_hash: string;\n counts: Record<string, number>;\n total: number;\n titles: Record<string, string[]>;\n}\n\nexport interface AssessmentLogEntry {\n date: string;\n type: 'assessment';\n verdict?: string;\n precision?: string;\n comment?: string;\n}\n\nexport type LogEntry = CountLogEntry | PreviewLogEntry | AssessmentLogEntry;\n\n// ── Helpers ─────────────────────────────────────────────────────────\n\n/**\n * Derive the log file path from a query file path.\n * `{dir}/{basename}.yaml` → `{dir}/{basename}.search-log.yaml`\n */\nexport function getLogFilePath(queryFilePath: string): string {\n const dir = dirname(queryFilePath);\n const base = basename(queryFilePath).replace(/\\.ya?ml$/, '');\n const logName = `${base}.search-log.yaml`;\n return dir === '.' ? logName : join(dir, logName);\n}\n\n/**\n * Generate a timestamp in \"YYYY-MM-DD HH:mm\" format.\n */\nexport function formatTimestamp(date: Date = new Date()): string {\n const year = date.getFullYear();\n const month = String(date.getMonth() + 1).padStart(2, '0');\n const day = String(date.getDate()).padStart(2, '0');\n const hours = String(date.getHours()).padStart(2, '0');\n const minutes = String(date.getMinutes()).padStart(2, '0');\n return `${year}-${month}-${day} ${hours}:${minutes}`;\n}\n\n/**\n * Compute a short hash of the query file content.\n * Uses the same algorithm as session creation (SHA-256, first 8 hex chars).\n */\nexport function computeQueryHash(queryContent: string): string {\n return createHash('sha256').update(queryContent).digest('hex').slice(0, 8);\n}\n\n/**\n * Build a CountLogEntry from count-only results.\n */\nexport function buildCountLogEntry(\n queryHash: string,\n results: CountResult[],\n): CountLogEntry {\n const counts: Record<string, number> = {};\n let total = 0;\n for (const r of results) {\n if (!r.error) {\n counts[r.provider] = r.count;\n total += r.count;\n }\n }\n return {\n date: formatTimestamp(),\n type: 'count',\n query_hash: queryHash,\n counts,\n total,\n };\n}\n\n/**\n * Build a PreviewLogEntry from preview results.\n * Stores at most `maxTitles` titles per provider to avoid log bloat.\n */\nexport function buildPreviewLogEntry(\n queryHash: string,\n results: PreviewResult[],\n maxTitles = 5,\n): PreviewLogEntry {\n const counts: Record<string, number> = {};\n const titles: Record<string, string[]> = {};\n let total = 0;\n for (const r of results) {\n if (!r.error) {\n counts[r.provider] = r.count;\n total += r.count;\n if (r.titles.length > 0) {\n titles[r.provider] = r.titles.slice(0, maxTitles);\n }\n }\n }\n return {\n date: formatTimestamp(),\n type: 'preview',\n query_hash: queryHash,\n counts,\n total,\n titles,\n };\n}\n\n// ── Read / Write ────────────────────────────────────────────────────\n\n/**\n * Read raw content of the log file, preserving comments.\n * Returns null when the file does not exist.\n */\nasync function readRawLog(logPath: string): Promise<string | null> {\n try {\n return await readFile(logPath, 'utf-8');\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') {\n return null;\n }\n throw error;\n }\n}\n\n/**\n * Read log entries from the iteration log file for a given query file.\n * Returns an empty array when the log file does not exist or is empty.\n */\nexport async function readLogEntries(queryFilePath: string): Promise<LogEntry[]> {\n const logPath = getLogFilePath(queryFilePath);\n const content = await readRawLog(logPath);\n\n if (content === null || !content.trim()) {\n return [];\n }\n\n const parsed = parse(content);\n\n // YAML with only comments parses to null\n if (parsed === null || parsed === undefined) {\n return [];\n }\n\n if (!Array.isArray(parsed)) {\n return [];\n }\n\n // Filter out malformed entries that lack a valid type field\n return parsed.filter(\n (entry): entry is LogEntry =>\n entry !== null &&\n typeof entry === 'object' &&\n typeof entry.type === 'string' &&\n ['count', 'preview', 'assessment'].includes(entry.type),\n );\n}\n\n/**\n * Append a log entry to the iteration log file.\n * Creates the file with a header comment if it does not exist.\n */\nexport async function appendLogEntry(\n queryFilePath: string,\n entry: LogEntry,\n): Promise<void> {\n const logPath = getLogFilePath(queryFilePath);\n const queryBasename = basename(queryFilePath);\n const existing = await readRawLog(logPath);\n\n // Stringify the single entry as a YAML sequence item\n const entryYaml = stringify([entry], { lineWidth: 0 }).trimEnd();\n\n let content: string;\n if (existing === null) {\n const header =\n `# Search iteration log for ${queryBasename}\\n` +\n `# Auto-generated by search-hub. You can also edit this file manually.\\n\\n`;\n content = header + entryYaml + '\\n';\n } else {\n const trimmed = existing.trimEnd();\n content = trimmed + '\\n\\n' + entryYaml + '\\n';\n }\n\n await writeFile(logPath, content, 'utf-8');\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { LogEntry } from './iteration-log.js';
|
|
2
|
+
export interface LogOutputOptions {
|
|
3
|
+
json?: boolean | undefined;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Format log entries for display.
|
|
7
|
+
*/
|
|
8
|
+
export declare function formatLogOutput(entries: LogEntry[], options?: LogOutputOptions): string;
|
|
9
|
+
//# sourceMappingURL=log.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"log.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/query/log.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EACV,QAAQ,EAIT,MAAM,oBAAoB,CAAC;AAE5B,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CAC5B;AAED;;GAEG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,QAAQ,EAAE,EACnB,OAAO,CAAC,EAAE,gBAAgB,GACzB,MAAM,CA2BR"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
function formatLogOutput(entries, options) {
|
|
2
|
+
if (options?.json) {
|
|
3
|
+
return JSON.stringify(entries, null, 2);
|
|
4
|
+
}
|
|
5
|
+
if (entries.length === 0) {
|
|
6
|
+
return "No iteration log entries.";
|
|
7
|
+
}
|
|
8
|
+
const lines = [];
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
switch (entry.type) {
|
|
11
|
+
case "count":
|
|
12
|
+
lines.push(formatCountEntry(entry));
|
|
13
|
+
break;
|
|
14
|
+
case "preview":
|
|
15
|
+
lines.push(formatPreviewEntry(entry));
|
|
16
|
+
break;
|
|
17
|
+
case "assessment":
|
|
18
|
+
lines.push(formatAssessmentEntry(entry));
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
lines.push("");
|
|
22
|
+
}
|
|
23
|
+
return lines.join("\n").trimEnd();
|
|
24
|
+
}
|
|
25
|
+
function formatCountEntry(entry) {
|
|
26
|
+
const parts = [];
|
|
27
|
+
parts.push(`[${entry.date}] count (${entry.query_hash})`);
|
|
28
|
+
for (const [provider, count] of Object.entries(entry.counts)) {
|
|
29
|
+
parts.push(` ${provider}: ${count}`);
|
|
30
|
+
}
|
|
31
|
+
parts.push(` total: ${entry.total}`);
|
|
32
|
+
return parts.join("\n");
|
|
33
|
+
}
|
|
34
|
+
function formatPreviewEntry(entry) {
|
|
35
|
+
const parts = [];
|
|
36
|
+
parts.push(`[${entry.date}] preview (${entry.query_hash})`);
|
|
37
|
+
for (const [provider, count] of Object.entries(entry.counts)) {
|
|
38
|
+
parts.push(` ${provider}: ${count}`);
|
|
39
|
+
const providerTitles = entry.titles[provider];
|
|
40
|
+
if (providerTitles) {
|
|
41
|
+
for (const title of providerTitles) {
|
|
42
|
+
parts.push(` • ${title}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
parts.push(` total: ${entry.total}`);
|
|
47
|
+
return parts.join("\n");
|
|
48
|
+
}
|
|
49
|
+
function formatAssessmentEntry(entry) {
|
|
50
|
+
const parts = [];
|
|
51
|
+
const meta = [];
|
|
52
|
+
if (entry.verdict) meta.push(`verdict: ${entry.verdict}`);
|
|
53
|
+
if (entry.precision) meta.push(`precision: ${entry.precision}`);
|
|
54
|
+
const metaStr = meta.length > 0 ? ` ${meta.join(", ")}` : "";
|
|
55
|
+
parts.push(`[${entry.date}] assessment:${metaStr}`);
|
|
56
|
+
if (entry.comment) {
|
|
57
|
+
parts.push(` ${entry.comment}`);
|
|
58
|
+
}
|
|
59
|
+
return parts.join("\n");
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
formatLogOutput
|
|
63
|
+
};
|
|
64
|
+
//# sourceMappingURL=log.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"log.js","sources":["../../../../src/cli/commands/query/log.ts"],"sourcesContent":["/**\n * Query Log Command\n *\n * Formats and displays the query iteration log.\n */\nimport type {\n LogEntry,\n CountLogEntry,\n PreviewLogEntry,\n AssessmentLogEntry,\n} from './iteration-log.js';\n\nexport interface LogOutputOptions {\n json?: boolean | undefined;\n}\n\n/**\n * Format log entries for display.\n */\nexport function formatLogOutput(\n entries: LogEntry[],\n options?: LogOutputOptions,\n): string {\n if (options?.json) {\n return JSON.stringify(entries, null, 2);\n }\n\n if (entries.length === 0) {\n return 'No iteration log entries.';\n }\n\n const lines: string[] = [];\n\n for (const entry of entries) {\n switch (entry.type) {\n case 'count':\n lines.push(formatCountEntry(entry));\n break;\n case 'preview':\n lines.push(formatPreviewEntry(entry));\n break;\n case 'assessment':\n lines.push(formatAssessmentEntry(entry));\n break;\n }\n lines.push('');\n }\n\n return lines.join('\\n').trimEnd();\n}\n\nfunction formatCountEntry(entry: CountLogEntry): string {\n const parts: string[] = [];\n parts.push(`[${entry.date}] count (${entry.query_hash})`);\n for (const [provider, count] of Object.entries(entry.counts)) {\n parts.push(` ${provider}: ${count}`);\n }\n parts.push(` total: ${entry.total}`);\n return parts.join('\\n');\n}\n\nfunction formatPreviewEntry(entry: PreviewLogEntry): string {\n const parts: string[] = [];\n parts.push(`[${entry.date}] preview (${entry.query_hash})`);\n for (const [provider, count] of Object.entries(entry.counts)) {\n parts.push(` ${provider}: ${count}`);\n const providerTitles = entry.titles[provider];\n if (providerTitles) {\n for (const title of providerTitles) {\n parts.push(` • ${title}`);\n }\n }\n }\n parts.push(` total: ${entry.total}`);\n return parts.join('\\n');\n}\n\nfunction formatAssessmentEntry(entry: AssessmentLogEntry): string {\n const parts: string[] = [];\n const meta: string[] = [];\n if (entry.verdict) meta.push(`verdict: ${entry.verdict}`);\n if (entry.precision) meta.push(`precision: ${entry.precision}`);\n const metaStr = meta.length > 0 ? ` ${meta.join(', ')}` : '';\n parts.push(`[${entry.date}] assessment:${metaStr}`);\n if (entry.comment) {\n parts.push(` ${entry.comment}`);\n }\n return parts.join('\\n');\n}\n"],"names":[],"mappings":"AAmBO,SAAS,gBACd,SACA,SACQ;AACR,MAAI,SAAS,MAAM;AACjB,WAAO,KAAK,UAAU,SAAS,MAAM,CAAC;AAAA,EACxC;AAEA,MAAI,QAAQ,WAAW,GAAG;AACxB,WAAO;AAAA,EACT;AAEA,QAAM,QAAkB,CAAA;AAExB,aAAW,SAAS,SAAS;AAC3B,YAAQ,MAAM,MAAA;AAAA,MACZ,KAAK;AACH,cAAM,KAAK,iBAAiB,KAAK,CAAC;AAClC;AAAA,MACF,KAAK;AACH,cAAM,KAAK,mBAAmB,KAAK,CAAC;AACpC;AAAA,MACF,KAAK;AACH,cAAM,KAAK,sBAAsB,KAAK,CAAC;AACvC;AAAA,IAAA;AAEJ,UAAM,KAAK,EAAE;AAAA,EACf;AAEA,SAAO,MAAM,KAAK,IAAI,EAAE,QAAA;AAC1B;AAEA,SAAS,iBAAiB,OAA8B;AACtD,QAAM,QAAkB,CAAA;AACxB,QAAM,KAAK,IAAI,MAAM,IAAI,YAAY,MAAM,UAAU,GAAG;AACxD,aAAW,CAAC,UAAU,KAAK,KAAK,OAAO,QAAQ,MAAM,MAAM,GAAG;AAC5D,UAAM,KAAK,KAAK,QAAQ,KAAK,KAAK,EAAE;AAAA,EACtC;AACA,QAAM,KAAK,YAAY,MAAM,KAAK,EAAE;AACpC,SAAO,MAAM,KAAK,IAAI;AACxB;AAEA,SAAS,mBAAmB,OAAgC;AAC1D,QAAM,QAAkB,CAAA;AACxB,QAAM,KAAK,IAAI,MAAM,IAAI,cAAc,MAAM,UAAU,GAAG;AAC1D,aAAW,CAAC,UAAU,KAAK,KAAK,OAAO,QAAQ,MAAM,MAAM,GAAG;AAC5D,UAAM,KAAK,KAAK,QAAQ,KAAK,KAAK,EAAE;AACpC,UAAM,iBAAiB,MAAM,OAAO,QAAQ;AAC5C,QAAI,gBAAgB;AAClB,iBAAW,SAAS,gBAAgB;AAClC,cAAM,KAAK,SAAS,KAAK,EAAE;AAAA,MAC7B;AAAA,IACF;AAAA,EACF;AACA,QAAM,KAAK,YAAY,MAAM,KAAK,EAAE;AACpC,SAAO,MAAM,KAAK,IAAI;AACxB;AAEA,SAAS,sBAAsB,OAAmC;AAChE,QAAM,QAAkB,CAAA;AACxB,QAAM,OAAiB,CAAA;AACvB,MAAI,MAAM,QAAS,MAAK,KAAK,YAAY,MAAM,OAAO,EAAE;AACxD,MAAI,MAAM,UAAW,MAAK,KAAK,cAAc,MAAM,SAAS,EAAE;AAC9D,QAAM,UAAU,KAAK,SAAS,IAAI,IAAI,KAAK,KAAK,IAAI,CAAC,KAAK;AAC1D,QAAM,KAAK,IAAI,MAAM,IAAI,gBAAgB,OAAO,EAAE;AAClD,MAAI,MAAM,SAAS;AACjB,UAAM,KAAK,KAAK,MAAM,OAAO,EAAE;AAAA,EACjC;AACA,SAAO,MAAM,KAAK,IAAI;AACxB;"}
|
|
@@ -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,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,
|
|
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,CAkH9B"}
|
|
@@ -167,10 +167,14 @@ async function executeReviewExtract(options, sessionsDir) {
|
|
|
167
167
|
};
|
|
168
168
|
const yamlContent = stringify(outputFile, { lineWidth: 0 });
|
|
169
169
|
const decisionComment = getDecisionInlineComment(options.basis);
|
|
170
|
-
|
|
170
|
+
let yamlWithComments = yamlContent.replace(
|
|
171
171
|
/^(\s*-?\s*)decision: uncertain$/gm,
|
|
172
172
|
`$1decision: uncertain ${decisionComment}`
|
|
173
173
|
);
|
|
174
|
+
yamlWithComments = yamlWithComments.replace(
|
|
175
|
+
/^(\s*)comment: ""$/gm,
|
|
176
|
+
'$1comment: "" # reason for decision'
|
|
177
|
+
);
|
|
174
178
|
const guidanceComment = getBasisGuidanceComment(options.basis);
|
|
175
179
|
finalContent = guidanceComment + yamlWithComments;
|
|
176
180
|
} else {
|
|
@@ -180,10 +184,14 @@ async function executeReviewExtract(options, sessionsDir) {
|
|
|
180
184
|
articles: paginated.map((article) => buildFinalizeArticle(article, options.basis))
|
|
181
185
|
};
|
|
182
186
|
const yamlContent = stringify(outputFile, { lineWidth: 0 });
|
|
183
|
-
|
|
187
|
+
let yamlWithComments = yamlContent.replace(
|
|
184
188
|
/^(\s*)finalDecision: null$/gm,
|
|
185
189
|
"$1finalDecision: # include / exclude"
|
|
186
190
|
);
|
|
191
|
+
yamlWithComments = yamlWithComments.replace(
|
|
192
|
+
/^(\s*)reviews: \[\]$/gm,
|
|
193
|
+
"$1reviews: [] # add new reviews here"
|
|
194
|
+
);
|
|
187
195
|
const guidanceComment = getFinalDecisionGuidanceComment();
|
|
188
196
|
finalContent = guidanceComment + yamlWithComments;
|
|
189
197
|
}
|
|
@@ -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 const yamlWithComments = yamlContent.replace(\n /^(\\s*-?\\s*)decision: uncertain$/gm,\n `$1decision: uncertain ${decisionComment}`\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 const yamlWithComments = yamlContent.replace(\n /^(\\s*)finalDecision: null$/gm,\n '$1finalDecision: # include / exclude'\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 } 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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"}
|
|
@@ -3,6 +3,7 @@ export interface ReviewFinalizeOptions {
|
|
|
3
3
|
sessionId: string;
|
|
4
4
|
dryRun?: boolean;
|
|
5
5
|
minReviewers?: number;
|
|
6
|
+
decision?: 'include' | 'exclude';
|
|
6
7
|
}
|
|
7
8
|
export interface ReviewFinalizeResult {
|
|
8
9
|
includedCount: number;
|
|
@@ -18,5 +19,6 @@ export declare function executeReviewFinalize(options: ReviewFinalizeOptions, se
|
|
|
18
19
|
*/
|
|
19
20
|
export declare function formatFinalizeOutput(result: ReviewFinalizeResult, options?: {
|
|
20
21
|
dryRun?: boolean;
|
|
22
|
+
decision?: 'include' | 'exclude';
|
|
21
23
|
}): string;
|
|
22
24
|
//# sourceMappingURL=finalize.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"finalize.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/finalize.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,OAAO,EAAmC,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAEhF,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"finalize.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/finalize.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,OAAO,EAAmC,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAEhF,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;CAClC;AAED,MAAM,WAAW,oBAAoB;IACnC,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC;CAC/C;AAcD;;GAEG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,qBAAqB,EAC9B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,oBAAoB,CAAC,CAwD/B;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;CAAE,GAC/D,MAAM,CAuCR"}
|
|
@@ -28,6 +28,11 @@ async function executeReviewFinalize(options, sessionsDir) {
|
|
|
28
28
|
for (const article of reviewFile.articles) {
|
|
29
29
|
const status = classifyStatus(article, reviewers);
|
|
30
30
|
if (status === "agreed-include" || status === "agreed-exclude") {
|
|
31
|
+
const consensusDecision = status === "agreed-include" ? "include" : "exclude";
|
|
32
|
+
if (options.decision && options.decision !== consensusDecision) {
|
|
33
|
+
result.skippedByStatus[status]++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
31
36
|
const reviews = article.reviews ?? [];
|
|
32
37
|
const uniqueReviewers = new Set(reviews.map((r) => r.reviewer));
|
|
33
38
|
if (uniqueReviewers.size < minReviewers) {
|
|
@@ -35,7 +40,7 @@ async function executeReviewFinalize(options, sessionsDir) {
|
|
|
35
40
|
continue;
|
|
36
41
|
}
|
|
37
42
|
if (!options.dryRun) {
|
|
38
|
-
article.finalDecision =
|
|
43
|
+
article.finalDecision = consensusDecision;
|
|
39
44
|
}
|
|
40
45
|
if (status === "agreed-include") {
|
|
41
46
|
result.includedCount++;
|
|
@@ -75,6 +80,12 @@ function formatFinalizeOutput(result, options) {
|
|
|
75
80
|
if (result.skippedByStatus.conflicting > 0) {
|
|
76
81
|
skippedParts.push(`${result.skippedByStatus.conflicting} conflicting`);
|
|
77
82
|
}
|
|
83
|
+
if (options?.decision && result.skippedByStatus["agreed-include"] > 0) {
|
|
84
|
+
skippedParts.push(`${result.skippedByStatus["agreed-include"]} agreed-include (filtered)`);
|
|
85
|
+
}
|
|
86
|
+
if (options?.decision && result.skippedByStatus["agreed-exclude"] > 0) {
|
|
87
|
+
skippedParts.push(`${result.skippedByStatus["agreed-exclude"]} agreed-exclude (filtered)`);
|
|
88
|
+
}
|
|
78
89
|
if (skippedParts.length > 0) {
|
|
79
90
|
lines.push(`Skipped: ${skippedParts.join(", ")}`);
|
|
80
91
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"finalize.js","sources":["../../../../src/cli/commands/review/finalize.ts"],"sourcesContent":["/**\n * review finalize command - Auto-set finalDecision for articles with consensus\n */\n\nimport { join } from 'node:path';\nimport { readFile, writeFile } from 'node:fs/promises';\nimport { parse as parseYaml, stringify as stringifyYaml } from 'yaml';\nimport { classifyStatus, type ReviewFile, type ReviewStatus } from './types.js';\n\nexport interface ReviewFinalizeOptions {\n sessionId: string;\n dryRun?: boolean;\n minReviewers?: number;\n}\n\nexport interface ReviewFinalizeResult {\n includedCount: number;\n excludedCount: number;\n skippedByStatus: Record<ReviewStatus, number>;\n}\n\nfunction createEmptySkippedByStatus(): Record<ReviewStatus, number> {\n return {\n pending: 0,\n incomplete: 0,\n uncertain: 0,\n 'agreed-include': 0,\n 'agreed-exclude': 0,\n conflicting: 0,\n finalized: 0,\n };\n}\n\n/**\n * Execute review finalize command\n */\nexport async function executeReviewFinalize(\n options: ReviewFinalizeOptions,\n sessionsDir: string\n): Promise<ReviewFinalizeResult> {\n const sessionDir = join(sessionsDir, options.sessionId);\n const reviewsPath = join(sessionDir, '.internal', 'reviews.yaml');\n const content = await readFile(reviewsPath, 'utf-8');\n const reviewFile = parseYaml(content) as ReviewFile;\n\n const reviewers = reviewFile.reviewers ?? [];\n const minReviewers = options.minReviewers ?? 1;\n\n const result: ReviewFinalizeResult = {\n includedCount: 0,\n excludedCount: 0,\n skippedByStatus: createEmptySkippedByStatus(),\n };\n\n for (const article of reviewFile.articles) {\n const status = classifyStatus(article, reviewers);\n\n if (status === 'agreed-include' || status === 'agreed-exclude') {\n // Check minimum reviewer count\n const reviews = article.reviews ?? [];\n const uniqueReviewers = new Set(reviews.map((r) => r.reviewer));\n if (uniqueReviewers.size < minReviewers) {\n result.skippedByStatus[status]++;\n continue;\n }\n\n if (!options.dryRun) {\n article.finalDecision =
|
|
1
|
+
{"version":3,"file":"finalize.js","sources":["../../../../src/cli/commands/review/finalize.ts"],"sourcesContent":["/**\n * review finalize command - Auto-set finalDecision for articles with consensus\n */\n\nimport { join } from 'node:path';\nimport { readFile, writeFile } from 'node:fs/promises';\nimport { parse as parseYaml, stringify as stringifyYaml } from 'yaml';\nimport { classifyStatus, type ReviewFile, type ReviewStatus } from './types.js';\n\nexport interface ReviewFinalizeOptions {\n sessionId: string;\n dryRun?: boolean;\n minReviewers?: number;\n decision?: 'include' | 'exclude';\n}\n\nexport interface ReviewFinalizeResult {\n includedCount: number;\n excludedCount: number;\n skippedByStatus: Record<ReviewStatus, number>;\n}\n\nfunction createEmptySkippedByStatus(): Record<ReviewStatus, number> {\n return {\n pending: 0,\n incomplete: 0,\n uncertain: 0,\n 'agreed-include': 0,\n 'agreed-exclude': 0,\n conflicting: 0,\n finalized: 0,\n };\n}\n\n/**\n * Execute review finalize command\n */\nexport async function executeReviewFinalize(\n options: ReviewFinalizeOptions,\n sessionsDir: string\n): Promise<ReviewFinalizeResult> {\n const sessionDir = join(sessionsDir, options.sessionId);\n const reviewsPath = join(sessionDir, '.internal', 'reviews.yaml');\n const content = await readFile(reviewsPath, 'utf-8');\n const reviewFile = parseYaml(content) as ReviewFile;\n\n const reviewers = reviewFile.reviewers ?? [];\n const minReviewers = options.minReviewers ?? 1;\n\n const result: ReviewFinalizeResult = {\n includedCount: 0,\n excludedCount: 0,\n skippedByStatus: createEmptySkippedByStatus(),\n };\n\n for (const article of reviewFile.articles) {\n const status = classifyStatus(article, reviewers);\n\n if (status === 'agreed-include' || status === 'agreed-exclude') {\n // Check decision filter\n const consensusDecision = status === 'agreed-include' ? 'include' : 'exclude';\n if (options.decision && options.decision !== consensusDecision) {\n result.skippedByStatus[status]++;\n continue;\n }\n\n // Check minimum reviewer count\n const reviews = article.reviews ?? [];\n const uniqueReviewers = new Set(reviews.map((r) => r.reviewer));\n if (uniqueReviewers.size < minReviewers) {\n result.skippedByStatus[status]++;\n continue;\n }\n\n if (!options.dryRun) {\n article.finalDecision = consensusDecision;\n }\n\n if (status === 'agreed-include') {\n result.includedCount++;\n } else {\n result.excludedCount++;\n }\n } else {\n result.skippedByStatus[status]++;\n }\n }\n\n // Write back if not dry-run\n if (!options.dryRun) {\n const yamlContent = stringifyYaml(reviewFile, { lineWidth: 0 });\n const schemaComment = `# yaml-language-server: $schema=./review.schema.json\\n`;\n await writeFile(reviewsPath, schemaComment + yamlContent, 'utf-8');\n }\n\n return result;\n}\n\n/**\n * Format finalize result as human-readable string\n */\nexport function formatFinalizeOutput(\n result: ReviewFinalizeResult,\n options?: { dryRun?: boolean; decision?: 'include' | 'exclude' }\n): string {\n const lines: string[] = [];\n\n if (options?.dryRun) {\n lines.push('Dry run - no changes made');\n lines.push('');\n }\n\n const total = result.includedCount + result.excludedCount;\n lines.push(`Finalized ${total} articles (${result.includedCount} include, ${result.excludedCount} exclude)`);\n\n // Build skipped summary (only non-zero, non-agreed statuses)\n const skippedParts: string[] = [];\n if (result.skippedByStatus.pending > 0) {\n skippedParts.push(`${result.skippedByStatus.pending} pending`);\n }\n if (result.skippedByStatus.incomplete > 0) {\n skippedParts.push(`${result.skippedByStatus.incomplete} incomplete`);\n }\n if (result.skippedByStatus.uncertain > 0) {\n skippedParts.push(`${result.skippedByStatus.uncertain} uncertain`);\n }\n if (result.skippedByStatus.conflicting > 0) {\n skippedParts.push(`${result.skippedByStatus.conflicting} conflicting`);\n }\n\n // Show filtered-out agreed counts when --decision is active\n if (options?.decision && result.skippedByStatus['agreed-include'] > 0) {\n skippedParts.push(`${result.skippedByStatus['agreed-include']} agreed-include (filtered)`);\n }\n if (options?.decision && result.skippedByStatus['agreed-exclude'] > 0) {\n skippedParts.push(`${result.skippedByStatus['agreed-exclude']} agreed-exclude (filtered)`);\n }\n\n if (skippedParts.length > 0) {\n lines.push(`Skipped: ${skippedParts.join(', ')}`);\n }\n\n return lines.join('\\n');\n}\n"],"names":["parseYaml","stringifyYaml"],"mappings":"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"}
|
|
@@ -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;AAYH,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,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,CA6E3B"}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { join
|
|
2
|
-
import { access, mkdir, writeFile
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { access, mkdir, writeFile } from "node:fs/promises";
|
|
3
3
|
import { stringify } from "yaml";
|
|
4
4
|
import { loadSession } from "../../../session/manager.js";
|
|
5
5
|
import { loadResults } from "../../../session/results-io.js";
|
|
6
6
|
import { deduplicateForReview } from "./dedup.js";
|
|
7
|
+
import { generateReviewJSONSchema } from "./schema.js";
|
|
7
8
|
function formatAuthors(authors) {
|
|
8
9
|
return authors.map((a) => {
|
|
9
10
|
const parts = [];
|
|
@@ -38,20 +39,6 @@ function articleToEntry(article) {
|
|
|
38
39
|
}
|
|
39
40
|
return entry;
|
|
40
41
|
}
|
|
41
|
-
async function findSchemaSource() {
|
|
42
|
-
const possiblePaths = [
|
|
43
|
-
join(dirname(import.meta.url.replace("file://", "")), "../../../../schemas/review.schema.json"),
|
|
44
|
-
join(process.cwd(), "schemas/review.schema.json")
|
|
45
|
-
];
|
|
46
|
-
for (const path of possiblePaths) {
|
|
47
|
-
try {
|
|
48
|
-
await access(path);
|
|
49
|
-
return path;
|
|
50
|
-
} catch {
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
throw new Error("Could not find review.schema.json");
|
|
54
|
-
}
|
|
55
42
|
async function executeReviewInit(options, sessionsDir) {
|
|
56
43
|
const sessionDir = join(sessionsDir, options.sessionId);
|
|
57
44
|
const session = await loadSession(options.sessionId, sessionsDir);
|
|
@@ -98,11 +85,8 @@ async function executeReviewInit(options, sessionsDir) {
|
|
|
98
85
|
);
|
|
99
86
|
await writeFile(reviewsPath, finalContent, "utf-8");
|
|
100
87
|
const schemaDestPath = join(internalDir, "review.schema.json");
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
await copyFile(schemaSourcePath, schemaDestPath);
|
|
104
|
-
} catch {
|
|
105
|
-
}
|
|
88
|
+
const jsonSchema = generateReviewJSONSchema();
|
|
89
|
+
await writeFile(schemaDestPath, JSON.stringify(jsonSchema, null, 2) + "\n", "utf-8");
|
|
106
90
|
return {
|
|
107
91
|
reviewsPath,
|
|
108
92
|
articleCount: articleEntries.length,
|