skillfid 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/package.json +39 -0
- package/src/baseline.js +85 -0
- package/src/calibration.js +32 -0
- package/src/cli.js +329 -0
- package/src/concurrency.js +77 -0
- package/src/copilot-sdk.js +137 -0
- package/src/corpus.js +112 -0
- package/src/dataset.js +486 -0
- package/src/diagnosis.js +40 -0
- package/src/evaluation.js +486 -0
- package/src/files.js +56 -0
- package/src/inventory.js +83 -0
- package/src/journal.js +193 -0
- package/src/json.js +26 -0
- package/src/judge.js +39 -0
- package/src/progress.js +223 -0
- package/src/prompts.js +42 -0
- package/src/questions.js +80 -0
- package/src/report.js +233 -0
- package/src/structured.js +35 -0
- package/src/verification.js +33 -0
- package/templates/evaluation-report.html +537 -0
package/src/journal.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
|
|
6
|
+
import { stableStringify } from './json.js';
|
|
7
|
+
|
|
8
|
+
export function operationId(kind, inputs) {
|
|
9
|
+
const hash = createHash('sha256').update(stableStringify({ kind, inputs }), 'utf8').digest('hex');
|
|
10
|
+
return `${kind}_${hash.slice(0, 16)}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function valueHash(value) {
|
|
14
|
+
return createHash('sha256').update(stableStringify(value), 'utf8').digest('hex');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class OperationJournal {
|
|
18
|
+
static async open(filePath, options = {}) {
|
|
19
|
+
if (!options.readOnly) await mkdir(path.dirname(path.resolve(filePath)), { recursive: true });
|
|
20
|
+
return new OperationJournal(filePath, options);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
constructor(filePath, { clock = () => Date.now(), leaseMs = 15 * 60 * 1000, readOnly = false } = {}) {
|
|
24
|
+
this.clock = clock;
|
|
25
|
+
this.leaseMs = leaseMs;
|
|
26
|
+
this.database = new DatabaseSync(path.resolve(filePath), { readOnly });
|
|
27
|
+
if (readOnly) {
|
|
28
|
+
this.database.exec('PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
this.database.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;');
|
|
32
|
+
this.database.exec(`
|
|
33
|
+
CREATE TABLE IF NOT EXISTS operations (
|
|
34
|
+
operation_id TEXT PRIMARY KEY,
|
|
35
|
+
kind TEXT NOT NULL,
|
|
36
|
+
input_hash TEXT NOT NULL,
|
|
37
|
+
config_json TEXT NOT NULL,
|
|
38
|
+
status TEXT NOT NULL CHECK(status IN ('running', 'completed', 'failed')),
|
|
39
|
+
publication_path TEXT,
|
|
40
|
+
created_at INTEGER NOT NULL,
|
|
41
|
+
updated_at INTEGER NOT NULL
|
|
42
|
+
);
|
|
43
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
44
|
+
job_id TEXT PRIMARY KEY,
|
|
45
|
+
operation_id TEXT NOT NULL REFERENCES operations(operation_id),
|
|
46
|
+
stage TEXT NOT NULL,
|
|
47
|
+
entity_id TEXT NOT NULL,
|
|
48
|
+
input_hash TEXT NOT NULL,
|
|
49
|
+
status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'completed', 'failed')),
|
|
50
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
51
|
+
lease_owner TEXT,
|
|
52
|
+
lease_expires_at INTEGER,
|
|
53
|
+
output_json TEXT,
|
|
54
|
+
output_hash TEXT,
|
|
55
|
+
error TEXT,
|
|
56
|
+
created_at INTEGER NOT NULL,
|
|
57
|
+
updated_at INTEGER NOT NULL,
|
|
58
|
+
UNIQUE(operation_id, stage, entity_id, input_hash)
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS jobs_claim_idx ON jobs(operation_id, stage, status, lease_expires_at, created_at);
|
|
61
|
+
`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
close() {
|
|
65
|
+
this.database.close();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
startOperation({ operationId: id, kind, inputs, config = {} }) {
|
|
69
|
+
const now = this.clock();
|
|
70
|
+
const inputHash = valueHash(inputs);
|
|
71
|
+
this.database.prepare(`
|
|
72
|
+
INSERT INTO operations (operation_id, kind, input_hash, config_json, status, created_at, updated_at)
|
|
73
|
+
VALUES (?, ?, ?, ?, 'running', ?, ?)
|
|
74
|
+
ON CONFLICT(operation_id) DO UPDATE SET status = 'running', updated_at = excluded.updated_at
|
|
75
|
+
WHERE operations.input_hash = excluded.input_hash
|
|
76
|
+
`).run(id, kind, inputHash, stableStringify(config), now, now);
|
|
77
|
+
const operation = this.getOperation(id);
|
|
78
|
+
if (!operation || operation.inputHash !== inputHash) throw new Error(`Operation identity collision for ${id}`);
|
|
79
|
+
return operation;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getOperation(id) {
|
|
83
|
+
const row = this.database.prepare('SELECT * FROM operations WHERE operation_id = ?').get(id);
|
|
84
|
+
return row ? mapOperation(row) : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
listOperations() {
|
|
88
|
+
return this.database.prepare('SELECT * FROM operations ORDER BY updated_at DESC, operation_id').all().map(mapOperation);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
findResumableOperation(kind, inputs) {
|
|
92
|
+
const row = this.database.prepare(`
|
|
93
|
+
SELECT * FROM operations
|
|
94
|
+
WHERE kind = ? AND input_hash = ? AND status != 'completed'
|
|
95
|
+
ORDER BY updated_at DESC, created_at DESC, operation_id
|
|
96
|
+
LIMIT 1
|
|
97
|
+
`).get(kind, valueHash(inputs));
|
|
98
|
+
return row ? mapOperation(row) : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
ensureJob({ operationId: id, stage, entityId, inputs }) {
|
|
102
|
+
const now = this.clock();
|
|
103
|
+
const inputHash = valueHash(inputs);
|
|
104
|
+
const jobId = `job_${valueHash({ id, stage, entityId, inputHash }).slice(0, 20)}`;
|
|
105
|
+
this.database.prepare(`
|
|
106
|
+
INSERT OR IGNORE INTO jobs (job_id, operation_id, stage, entity_id, input_hash, status, created_at, updated_at)
|
|
107
|
+
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
|
|
108
|
+
`).run(jobId, id, stage, entityId, inputHash, now, now);
|
|
109
|
+
return this.getJob(jobId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
claimJob(jobId, workerId = randomUUID()) {
|
|
113
|
+
const now = this.clock();
|
|
114
|
+
const expires = now + this.leaseMs;
|
|
115
|
+
this.database.exec('BEGIN IMMEDIATE');
|
|
116
|
+
try {
|
|
117
|
+
const result = this.database.prepare(`
|
|
118
|
+
UPDATE jobs SET status = 'running', attempts = attempts + 1, lease_owner = ?, lease_expires_at = ?, error = NULL, updated_at = ?
|
|
119
|
+
WHERE job_id = ? AND (status IN ('pending', 'failed') OR (status = 'running' AND lease_expires_at <= ?))
|
|
120
|
+
`).run(workerId, expires, now, jobId, now);
|
|
121
|
+
this.database.exec('COMMIT');
|
|
122
|
+
return result.changes ? this.getJob(jobId) : undefined;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
this.database.exec('ROLLBACK');
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
renewLease(jobId, workerId) {
|
|
130
|
+
const now = this.clock();
|
|
131
|
+
const result = this.database.prepare(`
|
|
132
|
+
UPDATE jobs SET lease_expires_at = ?, updated_at = ?
|
|
133
|
+
WHERE job_id = ? AND status = 'running' AND lease_owner = ?
|
|
134
|
+
`).run(now + this.leaseMs, now, jobId, workerId);
|
|
135
|
+
return result.changes === 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
completeJob(jobId, workerId, output) {
|
|
139
|
+
const now = this.clock();
|
|
140
|
+
const outputJson = stableStringify(output);
|
|
141
|
+
const result = this.database.prepare(`
|
|
142
|
+
UPDATE jobs SET status = 'completed', output_json = ?, output_hash = ?, lease_owner = NULL, lease_expires_at = NULL, error = NULL, updated_at = ?
|
|
143
|
+
WHERE job_id = ? AND status = 'running' AND lease_owner = ?
|
|
144
|
+
`).run(outputJson, valueHash(output), now, jobId, workerId);
|
|
145
|
+
if (result.changes !== 1) throw new Error(`Worker ${workerId} does not own running job ${jobId}`);
|
|
146
|
+
return this.getJob(jobId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
failJob(jobId, workerId, error) {
|
|
150
|
+
const now = this.clock();
|
|
151
|
+
const result = this.database.prepare(`
|
|
152
|
+
UPDATE jobs SET status = 'failed', lease_owner = NULL, lease_expires_at = NULL, error = ?, updated_at = ?
|
|
153
|
+
WHERE job_id = ? AND status = 'running' AND lease_owner = ?
|
|
154
|
+
`).run(error instanceof Error ? error.message : String(error), now, jobId, workerId);
|
|
155
|
+
if (result.changes !== 1) throw new Error(`Worker ${workerId} does not own running job ${jobId}`);
|
|
156
|
+
return this.getJob(jobId);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
getJob(jobId) {
|
|
160
|
+
const row = this.database.prepare('SELECT * FROM jobs WHERE job_id = ?').get(jobId);
|
|
161
|
+
return row ? mapJob(row) : undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
listJobs(operationIdValue) {
|
|
165
|
+
return this.database.prepare('SELECT * FROM jobs WHERE operation_id = ? ORDER BY stage, entity_id, created_at').all(operationIdValue).map(mapJob);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
jobCounts(operationIdValue) {
|
|
169
|
+
const counts = { pending: 0, running: 0, completed: 0, failed: 0 };
|
|
170
|
+
for (const row of this.database.prepare('SELECT status, COUNT(*) AS count FROM jobs WHERE operation_id = ? GROUP BY status').all(operationIdValue)) counts[row.status] = Number(row.count);
|
|
171
|
+
return counts;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
completeOperation(id, publicationPath) {
|
|
175
|
+
this.assertAllJobsCompleted(id);
|
|
176
|
+
const now = this.clock();
|
|
177
|
+
this.database.prepare("UPDATE operations SET status = 'completed', publication_path = ?, updated_at = ? WHERE operation_id = ?").run(publicationPath, now, id);
|
|
178
|
+
return this.getOperation(id);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
assertAllJobsCompleted(id) {
|
|
182
|
+
const incomplete = this.database.prepare("SELECT COUNT(*) AS count FROM jobs WHERE operation_id = ? AND status != 'completed'").get(id).count;
|
|
183
|
+
if (incomplete) throw new Error(`Operation ${id} has ${incomplete} incomplete job(s)`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function mapOperation(row) {
|
|
188
|
+
return { operationId: row.operation_id, kind: row.kind, inputHash: row.input_hash, config: JSON.parse(row.config_json), status: row.status, publicationPath: row.publication_path, createdAt: row.created_at, updatedAt: row.updated_at };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function mapJob(row) {
|
|
192
|
+
return { jobId: row.job_id, operationId: row.operation_id, stage: row.stage, entityId: row.entity_id, inputHash: row.input_hash, status: row.status, attempts: row.attempts, leaseOwner: row.lease_owner, leaseExpiresAt: row.lease_expires_at, output: row.output_json ? JSON.parse(row.output_json) : undefined, outputHash: row.output_hash, error: row.error, createdAt: row.created_at, updatedAt: row.updated_at };
|
|
193
|
+
}
|
package/src/json.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function stableStringify(value, space) {
|
|
2
|
+
return JSON.stringify(sortValue(value), null, space);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function parseJsonObject(response, label = 'Response') {
|
|
6
|
+
let value;
|
|
7
|
+
try {
|
|
8
|
+
value = JSON.parse(response);
|
|
9
|
+
} catch (error) {
|
|
10
|
+
throw new InvalidStructuredResponse(`${label} is not valid JSON: ${error.message}`);
|
|
11
|
+
}
|
|
12
|
+
if (value === null || Array.isArray(value) || typeof value !== 'object') {
|
|
13
|
+
throw new InvalidStructuredResponse(`${label} must be a JSON object`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class InvalidStructuredResponse extends Error {}
|
|
19
|
+
|
|
20
|
+
function sortValue(value) {
|
|
21
|
+
if (Array.isArray(value)) return value.map(sortValue);
|
|
22
|
+
if (value !== null && typeof value === 'object') {
|
|
23
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortValue(value[key])]));
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
package/src/judge.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { stableStringify } from './json.js';
|
|
2
|
+
import { InvalidStructuredResponse, parseJsonObject } from './structured.js';
|
|
3
|
+
|
|
4
|
+
export class InvalidJudgeResponse extends InvalidStructuredResponse {}
|
|
5
|
+
|
|
6
|
+
export function buildJudgePrompt({ question, source, candidateAnswer, rubric }) {
|
|
7
|
+
return [
|
|
8
|
+
'Judge the candidate answer against each rubric criterion using only the complete supplied source as ground truth. Return only valid JSON with this shape:',
|
|
9
|
+
'{"criterionResults":[{"criterionIndex":0,"score":0,"rationale":"..."}],"unsupportedClaims":[]}',
|
|
10
|
+
'Scores must be numbers from 0 to 1. Include every criterion exactly once.',
|
|
11
|
+
'INPUT:',
|
|
12
|
+
stableStringify({ question, source, candidateAnswer, rubric }),
|
|
13
|
+
].join('\n');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseJudgeResponse(response, criterionCount) {
|
|
17
|
+
let data;
|
|
18
|
+
try {
|
|
19
|
+
data = parseJsonObject(response, 'Judge response');
|
|
20
|
+
} catch (error) {
|
|
21
|
+
throw new InvalidJudgeResponse(error.message);
|
|
22
|
+
}
|
|
23
|
+
if (!Array.isArray(data.criterionResults)) throw new InvalidJudgeResponse('criterionResults must be an array');
|
|
24
|
+
if (data.criterionResults.length !== criterionCount) throw new InvalidJudgeResponse(`Expected ${criterionCount} criterion results, got ${data.criterionResults.length}`);
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const criterionResults = data.criterionResults.map((item) => {
|
|
27
|
+
if (item === null || Array.isArray(item) || typeof item !== 'object') throw new InvalidJudgeResponse('Each criterion result must be an object');
|
|
28
|
+
const { criterionIndex, score, rationale } = item;
|
|
29
|
+
if (!Number.isInteger(criterionIndex)) throw new InvalidJudgeResponse('criterionIndex must be an integer');
|
|
30
|
+
if (criterionIndex < 0 || criterionIndex >= criterionCount || seen.has(criterionIndex)) throw new InvalidJudgeResponse(`Invalid or duplicate criterionIndex: ${criterionIndex}`);
|
|
31
|
+
if (typeof score !== 'number' || !Number.isFinite(score)) throw new InvalidJudgeResponse(`Criterion ${criterionIndex} score must be a number`);
|
|
32
|
+
if (score < 0 || score > 1) throw new InvalidJudgeResponse(`Criterion ${criterionIndex} score must be from 0 to 1`);
|
|
33
|
+
if (typeof rationale !== 'string' || !rationale.trim()) throw new InvalidJudgeResponse(`Criterion ${criterionIndex} rationale must not be empty`);
|
|
34
|
+
seen.add(criterionIndex);
|
|
35
|
+
return { criterionIndex, score, rationale: rationale.trim() };
|
|
36
|
+
}).sort((left, right) => left.criterionIndex - right.criterionIndex);
|
|
37
|
+
if (!Array.isArray(data.unsupportedClaims) || data.unsupportedClaims.some((claim) => typeof claim !== 'string')) throw new InvalidJudgeResponse('unsupportedClaims must be an array of strings');
|
|
38
|
+
return { criterionResults, unsupportedClaims: data.unsupportedClaims.map((claim) => claim.trim()).filter(Boolean) };
|
|
39
|
+
}
|
package/src/progress.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks';
|
|
2
|
+
|
|
3
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
4
|
+
const IMPORTANT_PATTERN = /(?:operation\s|jobs:|retrying|dataset ready:|evaluation complete:|dataset verification complete:|failed\b|error\b|interrupted)/i;
|
|
5
|
+
const IMPORTANT_TYPES = new Set(['checkpoint', 'complete', 'error', 'operation', 'retry']);
|
|
6
|
+
|
|
7
|
+
export function createProgressReporter({ mode = 'auto', intervalSeconds = 30, stream = process.stderr, startedAt = performance.now(), clock = () => performance.now() } = {}) {
|
|
8
|
+
const resolvedMode = mode === 'auto' ? (stream.isTTY ? 'human' : 'agent') : mode;
|
|
9
|
+
const options = { stream, startedAt, clock, intervalSeconds };
|
|
10
|
+
if (resolvedMode === 'quiet') return new QuietProgressReporter();
|
|
11
|
+
if (resolvedMode === 'human') return new HumanProgressReporter(options);
|
|
12
|
+
if (resolvedMode === 'agent') return new AgentProgressReporter(options);
|
|
13
|
+
if (resolvedMode === 'json') return new JsonProgressReporter(options);
|
|
14
|
+
throw new Error(`Unknown progress mode: ${mode}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ProgressReporter {
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
return createProgressReporter({ ...options, mode: options.mode ?? 'agent' });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class BaseProgressReporter {
|
|
24
|
+
constructor({ stream, startedAt, clock }) {
|
|
25
|
+
this.stream = stream;
|
|
26
|
+
this.startedAt = startedAt;
|
|
27
|
+
this.clock = clock;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
elapsedSeconds() {
|
|
31
|
+
return (this.clock() - this.startedAt) / 1000;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
close() {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class HumanProgressReporter extends BaseProgressReporter {
|
|
38
|
+
constructor(options) {
|
|
39
|
+
super(options);
|
|
40
|
+
this.state = { title: 'Working', current: 'Starting' };
|
|
41
|
+
this.estimate = { samples: [] };
|
|
42
|
+
this.frame = 0;
|
|
43
|
+
this.renderedLines = 0;
|
|
44
|
+
this.timer = setInterval(() => this.render(), 80);
|
|
45
|
+
this.timer.unref();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
report(input) {
|
|
49
|
+
const event = normalizeEvent(input);
|
|
50
|
+
if (!event.phase && !event.current && event.message && !IMPORTANT_TYPES.has(event.type)) event.current = event.message;
|
|
51
|
+
this.state = {
|
|
52
|
+
...this.state,
|
|
53
|
+
...pickDefined(event, ['workflow', 'title', 'phase', 'current', 'output', 'progress']),
|
|
54
|
+
metrics: event.metrics,
|
|
55
|
+
};
|
|
56
|
+
if (event.progress) this.recordProgress(event.progress);
|
|
57
|
+
if (event.type === 'complete') this.complete(event);
|
|
58
|
+
else if (event.type === 'error') this.notice('error', event.message ?? event.current ?? 'Failed');
|
|
59
|
+
else if (event.type === 'retry') this.notice('retry', event.message ?? 'Retrying');
|
|
60
|
+
else this.render();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
close() {
|
|
64
|
+
clearInterval(this.timer);
|
|
65
|
+
this.clear();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
render() {
|
|
69
|
+
if (this.finished) return;
|
|
70
|
+
const elapsed = this.elapsedSeconds();
|
|
71
|
+
const lines = [`\x1b[36m${SPINNER[this.frame++ % SPINNER.length]}\x1b[0m \x1b[1m${this.state.title}\x1b[0m`];
|
|
72
|
+
if (this.state.progress) {
|
|
73
|
+
const { done, total, label } = this.state.progress;
|
|
74
|
+
const ratio = total > 0 ? Math.min(1, done / total) : 0;
|
|
75
|
+
const percentageValue = done >= total ? 100 : Math.floor(ratio * 100);
|
|
76
|
+
const percentage = total > 0 ? ` \x1b[2m${percentageValue}%\x1b[0m` : '';
|
|
77
|
+
lines.push(` \x1b[36m${progressBar(ratio)}\x1b[0m \x1b[1m${done}/${total}\x1b[0m ${label}${percentage}`);
|
|
78
|
+
} else if (this.state.metrics) {
|
|
79
|
+
lines.push(...Object.entries(this.state.metrics).map(([label, value]) => ` ${label.padEnd(11)} ${value}`));
|
|
80
|
+
}
|
|
81
|
+
lines.push(` \x1b[2m${this.state.current ?? this.state.phase ?? 'Working'}\x1b[0m`);
|
|
82
|
+
lines.push(` \x1b[2m${formatElapsed(elapsed)} elapsed${formatRemaining(this.remainingSeconds(elapsed))}\x1b[0m`);
|
|
83
|
+
this.clear();
|
|
84
|
+
const width = Number.isInteger(this.stream.columns) ? Math.max(1, this.stream.columns - 1) : Infinity;
|
|
85
|
+
this.stream.write(lines.map((line) => fitTerminalLine(line, width)).join('\n'));
|
|
86
|
+
this.renderedLines = lines.length;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
complete(event) {
|
|
90
|
+
this.clear();
|
|
91
|
+
this.finished = true;
|
|
92
|
+
const lines = [`\x1b[32m[ok]\x1b[0m ${event.title ?? this.state.title} \x1b[2m${formatElapsed(this.elapsedSeconds())}\x1b[0m`];
|
|
93
|
+
for (const line of event.summary ?? []) lines.push(` ${line}`);
|
|
94
|
+
if (event.output) lines.push(` Output: ${event.output}`);
|
|
95
|
+
this.stream.write(`${lines.join('\n')}\n`);
|
|
96
|
+
this.state.current = undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
notice(kind, message) {
|
|
100
|
+
this.clear();
|
|
101
|
+
const marker = kind === 'error' ? '\x1b[31m[error]\x1b[0m' : '\x1b[33m[retry]\x1b[0m';
|
|
102
|
+
this.stream.write(`${marker} ${message}\n`);
|
|
103
|
+
this.render();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
recordProgress(progress) {
|
|
107
|
+
if (!Number.isFinite(progress.done) || !Number.isFinite(progress.total)) return;
|
|
108
|
+
const elapsed = this.elapsedSeconds();
|
|
109
|
+
const estimateDone = progress.etaDone ?? progress.done;
|
|
110
|
+
const last = this.estimate.samples.at(-1);
|
|
111
|
+
if (!last || estimateDone > last.done) {
|
|
112
|
+
this.estimate.samples.push({ done: estimateDone, elapsed });
|
|
113
|
+
this.estimate.samples = this.estimate.samples.slice(-8);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
remainingSeconds(elapsed) {
|
|
118
|
+
const progress = this.state.progress;
|
|
119
|
+
if (!progress || progress.done >= progress.total || progress.total <= 0) return undefined;
|
|
120
|
+
const samples = this.estimate.samples;
|
|
121
|
+
if (samples.length < 2 || elapsed < 5) return null;
|
|
122
|
+
const first = samples[0];
|
|
123
|
+
const last = samples.at(-1);
|
|
124
|
+
const rate = (last.done - first.done) / (last.elapsed - first.elapsed);
|
|
125
|
+
if (rate <= 0) return null;
|
|
126
|
+
const remainingAtLastSample = (progress.total - progress.done) / rate;
|
|
127
|
+
const remaining = remainingAtLastSample - (elapsed - last.elapsed);
|
|
128
|
+
return remaining > 0 ? remaining : Number.NaN;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
clear() {
|
|
132
|
+
if (!this.renderedLines) return;
|
|
133
|
+
this.stream.write('\r\x1b[2K');
|
|
134
|
+
for (let index = 1; index < this.renderedLines; index += 1) this.stream.write('\x1b[1A\r\x1b[2K');
|
|
135
|
+
this.renderedLines = 0;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
class AgentProgressReporter extends BaseProgressReporter {
|
|
140
|
+
constructor(options) {
|
|
141
|
+
super(options);
|
|
142
|
+
this.intervalSeconds = options.intervalSeconds;
|
|
143
|
+
this.lastWrittenAt = -Infinity;
|
|
144
|
+
this.suppressed = 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
report(input) {
|
|
148
|
+
const event = normalizeEvent(input);
|
|
149
|
+
const elapsed = this.elapsedSeconds();
|
|
150
|
+
const important = IMPORTANT_TYPES.has(event.type);
|
|
151
|
+
if (!important && elapsed - this.lastWrittenAt < this.intervalSeconds) { this.suppressed += 1; return; }
|
|
152
|
+
const suppressed = this.suppressed ? ` suppressed=${this.suppressed}` : '';
|
|
153
|
+
const phase = event.phase ? ` phase=${JSON.stringify(event.phase)}` : '';
|
|
154
|
+
const metrics = event.metrics && Object.keys(event.metrics).length ? ` metrics=${JSON.stringify(event.metrics)}` : '';
|
|
155
|
+
const message = event.message ?? event.current ?? event.title ?? event.type;
|
|
156
|
+
this.stream.write(`[progress] elapsed=${Math.round(elapsed)}s${suppressed}${phase}${metrics} message=${JSON.stringify(message)}\n`);
|
|
157
|
+
this.lastWrittenAt = elapsed;
|
|
158
|
+
this.suppressed = 0;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
class JsonProgressReporter extends BaseProgressReporter {
|
|
163
|
+
report(input) {
|
|
164
|
+
const event = normalizeEvent(input);
|
|
165
|
+
this.stream.write(`${JSON.stringify({ ...event, elapsedSeconds: Number(this.elapsedSeconds().toFixed(1)) })}\n`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
class QuietProgressReporter {
|
|
170
|
+
report() {}
|
|
171
|
+
close() {}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function eventType(message) {
|
|
175
|
+
if (/retrying/i.test(message)) return 'retry';
|
|
176
|
+
if (/jobs:/i.test(message)) return 'checkpoint';
|
|
177
|
+
if (/failed|error|interrupted/i.test(message)) return 'error';
|
|
178
|
+
if (/dataset ready:|evaluation complete:|dataset verification complete:/i.test(message)) return 'complete';
|
|
179
|
+
if (/operation\s/i.test(message)) return 'operation';
|
|
180
|
+
return 'progress';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizeEvent(input) {
|
|
184
|
+
if (typeof input === 'string') return { type: eventType(input), message: input };
|
|
185
|
+
if (!input || typeof input !== 'object') throw new TypeError('Progress events must be strings or objects');
|
|
186
|
+
return { type: 'update', ...input };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function pickDefined(source, keys) {
|
|
190
|
+
return Object.fromEntries(keys.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function formatElapsed(seconds) {
|
|
194
|
+
const rounded = Math.max(0, Math.round(seconds));
|
|
195
|
+
const minutes = Math.floor(rounded / 60);
|
|
196
|
+
const remainder = rounded % 60;
|
|
197
|
+
return minutes ? `${minutes}m ${String(remainder).padStart(2, '0')}s` : `${remainder}s`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function formatRemaining(seconds) {
|
|
201
|
+
if (seconds === undefined) return '';
|
|
202
|
+
if (seconds === null) return ' · estimating remaining time';
|
|
203
|
+
if (!Number.isFinite(seconds)) return ' · remaining time uncertain';
|
|
204
|
+
return ` · about ${formatElapsed(seconds)} remaining`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function progressBar(ratio, width = 16) {
|
|
208
|
+
const filled = ratio >= 1 ? width : Math.floor(ratio * width);
|
|
209
|
+
return `${'━'.repeat(filled)}${'─'.repeat(width - filled)}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function fitTerminalLine(line, width) {
|
|
213
|
+
if (!Number.isFinite(width)) return line;
|
|
214
|
+
const tokens = line.match(/\x1b\[[0-?]*[ -/]*[@-~]|./gu) ?? [];
|
|
215
|
+
let visible = 0;
|
|
216
|
+
let output = '';
|
|
217
|
+
for (const token of tokens) {
|
|
218
|
+
if (token.startsWith('\x1b[')) output += token;
|
|
219
|
+
else if (visible < width) { output += token; visible += 1; }
|
|
220
|
+
else return `${output}\x1b[0m`;
|
|
221
|
+
}
|
|
222
|
+
return output;
|
|
223
|
+
}
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { stableStringify } from './json.js';
|
|
2
|
+
|
|
3
|
+
export const ORACLE_PROMPT_VERSION = '3';
|
|
4
|
+
|
|
5
|
+
export function subjectPrompt(question) {
|
|
6
|
+
return ['Answer the user\'s question. Use an available skill when relevant.', 'Do not search external sources or use shell commands.', 'If the answer is not available in the provided context or an available skill, say that you do not know; do not infer or invent specific details.', 'Return only a concise final answer.', '', `User question: ${question}`].join('\n');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function skillPrompt(question, invocationMode, skillName) {
|
|
10
|
+
const prompt = subjectPrompt(question);
|
|
11
|
+
return invocationMode === 'explicit' ? [`/${skillName}`, '', prompt].join('\n') : prompt;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function oraclePrompt(question, source, rubric) {
|
|
15
|
+
const lines = [
|
|
16
|
+
'Answer the user\'s question using only the complete source below.',
|
|
17
|
+
'Answer every part of the question with the source\'s stated detail.',
|
|
18
|
+
'Preserve numbers, counts, qualifiers, causal claims, and scope exactly; do not round, broaden, combine, or strengthen them.',
|
|
19
|
+
'When the source gives apparently conflicting measurements from different contexts, distinguish those contexts explicitly.',
|
|
20
|
+
'If the source does not support a claim, omit it or state that it is not specified.',
|
|
21
|
+
'Do not use skills, external sources, or shell commands. Return only the final answer.',
|
|
22
|
+
'',
|
|
23
|
+
`User question: ${question}`,
|
|
24
|
+
];
|
|
25
|
+
if (rubric) lines.push(
|
|
26
|
+
'',
|
|
27
|
+
'Calibration coverage requirements:',
|
|
28
|
+
stableStringify(rubric.map(({ criterion }) => criterion)),
|
|
29
|
+
'Treat these requirements as minimum coverage, not an exhaustive answer boundary.',
|
|
30
|
+
'Before answering, scan the complete source independently of the requirements for every passage that addresses the question. Account for all applicable prerequisites, exceptions, and repeated measurements, including relevant passages outside the sections represented by the requirements.',
|
|
31
|
+
'Answer every requirement directly and answer the user\'s question fully, including relevant source facts and qualifications needed for accuracy and completeness.',
|
|
32
|
+
'If a requirement conflicts with the source, the source wins: report every distinct source-supported value or outcome with its location or context, and never repeat the requirement unqualified.',
|
|
33
|
+
'Treat the source as authoritative. Omit unrelated background, examples, and adjacent source facts.',
|
|
34
|
+
'Add no claim merely because it appears in a requirement; every claim must be supported by the source.',
|
|
35
|
+
);
|
|
36
|
+
lines.push(
|
|
37
|
+
'',
|
|
38
|
+
'Use only this complete source:',
|
|
39
|
+
stableStringify(source),
|
|
40
|
+
);
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|
package/src/questions.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { stableStringify } from './json.js';
|
|
4
|
+
import { InvalidStructuredResponse, parseJsonObject } from './structured.js';
|
|
5
|
+
import { requiredString } from './inventory.js';
|
|
6
|
+
|
|
7
|
+
export const MAX_ITEMS_PER_QUESTION = 6;
|
|
8
|
+
const QUESTION_TYPES = new Set(['fact', 'procedure', 'application', 'synthesis', 'conflict']);
|
|
9
|
+
const DIFFICULTIES = new Set(['easy', 'medium', 'hard']);
|
|
10
|
+
|
|
11
|
+
export function buildQuestionPrompt(items, evidence) {
|
|
12
|
+
const evidenceById = new Map(evidence.map((record) => [record.evidenceId, record.quote]));
|
|
13
|
+
const payload = items.map((item) => ({
|
|
14
|
+
knowledgeId: item.knowledgeId,
|
|
15
|
+
kind: item.kind,
|
|
16
|
+
statement: item.statement,
|
|
17
|
+
importance: item.importance,
|
|
18
|
+
evidence: item.evidenceIds.map((id) => evidenceById.get(id)),
|
|
19
|
+
}));
|
|
20
|
+
return [
|
|
21
|
+
'Generate focused, independently scorable, user-realistic questions that test every supplied knowledge item. Each item must be required by at least one rubric criterion. Each question must cover one coherent operational concern. Before writing questions, group items into the complete local concerns expressed by the source: a storage policy includes both placement and read behavior; a tier policy includes eligibility and backing media; a retention policy includes its rationale; an admission policy includes all paired limits, overload behavior, and stated risks. Preserve a source paragraph\'s coherent concern when it fits within the item limit instead of splitting its facts across questions.',
|
|
22
|
+
'Knowledge items may appear in more than one question when overlap is necessary to make related questions complete. Combine configuration with backing, policy with rationale, trigger with response, paired limits, and mechanism with purpose. Prefer one complete question over atomizing those relationships. Do not combine unrelated operational concerns merely to reduce the number of questions.',
|
|
23
|
+
`A question may cover at most ${MAX_ITEMS_PER_QUESTION} knowledge items.`,
|
|
24
|
+
'Do not include tested values, limits, outcomes, or other answer-bearing facts in the question. A model without the supplied knowledge must not be able to derive the answer from the wording or generic domain conventions. Every claim in each rubric criterion must be explicitly supported by the supplied evidence.',
|
|
25
|
+
'',
|
|
26
|
+
'Return only one JSON object with this shape:',
|
|
27
|
+
'{"questions":[{"question":"...","type":"fact","difficulty":"medium","rubric":[{"criterion":"...","weight":1,"knowledgeItemIds":["ki_..."]}]}]}',
|
|
28
|
+
'Allowed types: fact, procedure, application, synthesis, conflict. Allowed difficulties: easy, medium, hard. Rubric weights for each question must be positive and sum to 1. Use only supplied knowledge IDs.',
|
|
29
|
+
'',
|
|
30
|
+
'KNOWLEDGE INVENTORY:',
|
|
31
|
+
stableStringify(payload),
|
|
32
|
+
].join('\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseQuestionResponse(response, items) {
|
|
36
|
+
const data = parseJsonObject(response);
|
|
37
|
+
if (!Array.isArray(data.questions) || !data.questions.length) throw new InvalidStructuredResponse('questions must be a non-empty array');
|
|
38
|
+
const itemById = new Map(items.map((item) => [item.knowledgeId, item]));
|
|
39
|
+
const coveredIds = new Set();
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const questions = data.questions.map((rawQuestion) => {
|
|
42
|
+
if (rawQuestion === null || Array.isArray(rawQuestion) || typeof rawQuestion !== 'object') throw new InvalidStructuredResponse('Each question must be an object');
|
|
43
|
+
if ('referenceAnswer' in rawQuestion || 'reference_answer' in rawQuestion) throw new InvalidStructuredResponse('Questions must not include a reference answer; the source is ground truth');
|
|
44
|
+
const question = requiredString(rawQuestion, 'question');
|
|
45
|
+
const questionType = requiredString(rawQuestion, 'type');
|
|
46
|
+
const difficulty = requiredString(rawQuestion, 'difficulty');
|
|
47
|
+
if (!QUESTION_TYPES.has(questionType)) throw new InvalidStructuredResponse(`Unsupported question type: ${questionType}`);
|
|
48
|
+
if (!DIFFICULTIES.has(difficulty)) throw new InvalidStructuredResponse(`Unsupported difficulty: ${difficulty}`);
|
|
49
|
+
if (seen.has(question)) throw new InvalidStructuredResponse(`Duplicate question: ${question}`);
|
|
50
|
+
if (!Array.isArray(rawQuestion.rubric) || !rawQuestion.rubric.length) throw new InvalidStructuredResponse('rubric must be a non-empty array');
|
|
51
|
+
let totalWeight = 0;
|
|
52
|
+
const questionItemIds = new Set();
|
|
53
|
+
const rubric = rawQuestion.rubric.map((rawCriterion) => {
|
|
54
|
+
if (rawCriterion === null || Array.isArray(rawCriterion) || typeof rawCriterion !== 'object') throw new InvalidStructuredResponse('Each rubric criterion must be an object');
|
|
55
|
+
const criterion = requiredString(rawCriterion, 'criterion');
|
|
56
|
+
const { weight, knowledgeItemIds } = rawCriterion;
|
|
57
|
+
if (typeof weight !== 'number' || !Number.isFinite(weight) || weight <= 0) throw new InvalidStructuredResponse('Rubric weight must be a positive number');
|
|
58
|
+
if (!Array.isArray(knowledgeItemIds) || !knowledgeItemIds.length || knowledgeItemIds.some((id) => typeof id !== 'string')) throw new InvalidStructuredResponse('knowledgeItemIds must be a non-empty string array');
|
|
59
|
+
const unknown = [...new Set(knowledgeItemIds)].filter((id) => !itemById.has(id));
|
|
60
|
+
if (unknown.length) throw new InvalidStructuredResponse(`Rubric references unknown knowledge IDs: ${unknown.sort().join(', ')}`);
|
|
61
|
+
totalWeight += weight;
|
|
62
|
+
knowledgeItemIds.forEach((id) => questionItemIds.add(id));
|
|
63
|
+
return { criterion, weight, knowledgeItemIds: [...new Set(knowledgeItemIds)].sort() };
|
|
64
|
+
});
|
|
65
|
+
if (Math.abs(totalWeight - 1) > 1e-6) throw new InvalidStructuredResponse(`Rubric weights must sum to 1, got ${totalWeight}`);
|
|
66
|
+
if (questionItemIds.size > MAX_ITEMS_PER_QUESTION) throw new InvalidStructuredResponse(`A question may cover at most ${MAX_ITEMS_PER_QUESTION} knowledge items, got ${questionItemIds.size}`);
|
|
67
|
+
const evidenceIds = [...new Set([...questionItemIds].flatMap((id) => itemById.get(id).evidenceIds))].sort();
|
|
68
|
+
const testId = `ke_${digest(`${question}:${[...questionItemIds].sort().join(',')}`).slice(0, 16)}`;
|
|
69
|
+
questionItemIds.forEach((id) => coveredIds.add(id));
|
|
70
|
+
seen.add(question);
|
|
71
|
+
return { testId, question, questionType, difficulty, rubric, evidenceIds };
|
|
72
|
+
});
|
|
73
|
+
const missing = [...itemById.keys()].filter((id) => !coveredIds.has(id));
|
|
74
|
+
if (missing.length) throw new InvalidStructuredResponse(`Questions do not cover knowledge IDs: ${missing.sort().join(', ')}`);
|
|
75
|
+
return questions;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function digest(value) {
|
|
79
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
80
|
+
}
|