kc-beta 0.1.2 → 0.2.1

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.
@@ -1,102 +1,268 @@
1
+ import { withRetry } from "./retry.js";
2
+
1
3
  /**
2
- * Thin LLM client using native fetch + SSE parsing.
3
- * Replaces the Python openai.AsyncOpenAI client.
4
- * Supports OpenAI-compatible APIs (SiliconFlow, Aliyun, OpenAI, etc.)
4
+ * Multi-protocol LLM client using native fetch + SSE parsing.
5
+ * Supports OpenAI-compatible APIs and Anthropic Messages API.
5
6
  */
6
7
  export class LLMClient {
7
8
  /**
8
9
  * @param {object} opts
9
10
  * @param {string} opts.apiKey
10
- * @param {string} opts.baseUrl - e.g. "https://api.siliconflow.cn/v1"
11
+ * @param {string} opts.baseUrl - e.g. "https://api.siliconflow.cn/v1" or "https://api.anthropic.com"
12
+ * @param {string} [opts.authType] - "bearer" (default) | "x-api-key" (Anthropic)
13
+ * @param {string} [opts.apiFormat] - "openai" (default) | "anthropic"
11
14
  */
12
- constructor({ apiKey, baseUrl }) {
15
+ constructor({ apiKey, baseUrl, authType = "bearer", apiFormat = "openai" }) {
13
16
  this.apiKey = apiKey;
14
17
  this.baseUrl = baseUrl.replace(/\/+$/, "");
18
+ this.authType = authType;
19
+ this.apiFormat = apiFormat;
15
20
  }
16
21
 
17
22
  /**
18
- * Streaming chat completion. Yields parsed SSE chunk objects.
23
+ * Build auth headers based on provider type.
24
+ * @returns {object}
25
+ */
26
+ _buildHeaders() {
27
+ const headers = { "Content-Type": "application/json" };
28
+ if (this.authType === "x-api-key") {
29
+ headers["x-api-key"] = this.apiKey;
30
+ headers["anthropic-version"] = "2023-06-01";
31
+ } else if (this.authType === "aws-sigv4") {
32
+ throw new Error(
33
+ "AWS Bedrock authentication (SigV4) is not yet supported. " +
34
+ "Please use a different provider or an OpenAI-compatible proxy."
35
+ );
36
+ } else {
37
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
38
+ }
39
+ return headers;
40
+ }
41
+
42
+ /**
43
+ * Get the chat endpoint for the configured API format.
44
+ * @returns {string}
45
+ */
46
+ _getEndpoint() {
47
+ if (this.apiFormat === "anthropic") {
48
+ return `${this.baseUrl}/v1/messages`;
49
+ }
50
+ return `${this.baseUrl}/chat/completions`;
51
+ }
52
+
53
+ /**
54
+ * Build request body for the configured API format.
19
55
  * @param {object} opts
20
- * @param {string} opts.model
21
- * @param {Array} opts.messages
22
- * @param {Array} [opts.tools]
23
- * @param {number} [opts.maxTokens]
24
- * @yields {object} Parsed chunk from the SSE stream
56
+ * @returns {object}
25
57
  */
26
- async *streamChat({ model, messages, tools, maxTokens }) {
27
- const body = {
28
- model,
29
- messages,
30
- stream: true,
31
- };
58
+ _buildStreamBody({ model, messages, tools, maxTokens }) {
59
+ if (this.apiFormat === "anthropic") {
60
+ return this._buildAnthropicBody({ model, messages, tools, maxTokens, stream: true });
61
+ }
62
+ return this._buildOpenaiBody({ model, messages, tools, maxTokens, stream: true });
63
+ }
64
+
65
+ _buildNonStreamBody({ model, messages, maxTokens }) {
66
+ if (this.apiFormat === "anthropic") {
67
+ return this._buildAnthropicBody({ model, messages, tools: null, maxTokens, stream: false });
68
+ }
69
+ return this._buildOpenaiBody({ model, messages, tools: null, maxTokens, stream: false });
70
+ }
71
+
72
+ _buildOpenaiBody({ model, messages, tools, maxTokens, stream }) {
73
+ const body = { model, messages, stream };
32
74
  if (maxTokens) body.max_tokens = maxTokens;
33
75
  if (tools && tools.length > 0) body.tools = tools;
76
+ return body;
77
+ }
34
78
 
35
- const resp = await fetch(`${this.baseUrl}/chat/completions`, {
36
- method: "POST",
37
- headers: {
38
- "Authorization": `Bearer ${this.apiKey}`,
39
- "Content-Type": "application/json",
40
- },
41
- body: JSON.stringify(body),
42
- });
43
-
44
- if (!resp.ok) {
45
- const text = await resp.text();
46
- throw new Error(`LLM API error ${resp.status}: ${text}`);
79
+ _buildAnthropicBody({ model, messages, tools, maxTokens, stream }) {
80
+ // Anthropic: system message is a top-level field, not in messages array
81
+ let system = undefined;
82
+ const filteredMessages = [];
83
+ for (const msg of messages) {
84
+ if (msg.role === "system") {
85
+ system = (system ? system + "\n\n" : "") + msg.content;
86
+ } else if (msg.role === "tool") {
87
+ // Anthropic expects tool results as user messages with tool_result content blocks
88
+ filteredMessages.push({
89
+ role: "user",
90
+ content: [{
91
+ type: "tool_result",
92
+ tool_use_id: msg.tool_call_id,
93
+ content: msg.content,
94
+ }],
95
+ });
96
+ } else if (msg.role === "assistant" && msg.tool_calls) {
97
+ // Convert OpenAI tool_calls to Anthropic content blocks
98
+ const content = [];
99
+ if (msg.content) content.push({ type: "text", text: msg.content });
100
+ for (const tc of msg.tool_calls) {
101
+ let input = {};
102
+ try { input = JSON.parse(tc.function.arguments || "{}"); } catch { /* ignore */ }
103
+ content.push({
104
+ type: "tool_use",
105
+ id: tc.id,
106
+ name: tc.function.name,
107
+ input,
108
+ });
109
+ }
110
+ filteredMessages.push({ role: "assistant", content });
111
+ } else {
112
+ filteredMessages.push(msg);
113
+ }
47
114
  }
48
115
 
49
- for await (const data of this._parseSSE(resp.body)) {
50
- yield data;
116
+ const body = {
117
+ model,
118
+ messages: filteredMessages,
119
+ max_tokens: maxTokens || 8192,
120
+ stream,
121
+ };
122
+ if (system) body.system = system;
123
+ if (tools && tools.length > 0) {
124
+ // Convert OpenAI tool schema to Anthropic tool schema
125
+ body.tools = tools.map((t) => ({
126
+ name: t.function.name,
127
+ description: t.function.description || "",
128
+ input_schema: t.function.parameters || { type: "object", properties: {} },
129
+ }));
51
130
  }
131
+ return body;
52
132
  }
53
133
 
54
134
  /**
55
- * Non-streaming chat completion. Returns the full response.
135
+ * Streaming chat completion. Yields parsed SSE chunk objects
136
+ * normalized to OpenAI shape: { choices: [{ delta: { content?, tool_calls? } }] }
56
137
  * @param {object} opts
57
138
  * @param {string} opts.model
58
139
  * @param {Array} opts.messages
140
+ * @param {Array} [opts.tools]
59
141
  * @param {number} [opts.maxTokens]
60
- * @returns {object} The response object
142
+ * @yields {object} Normalized chunk
143
+ */
144
+ async *streamChat({ model, messages, tools, maxTokens }) {
145
+ const body = this._buildStreamBody({ model, messages, tools, maxTokens });
146
+
147
+ const resp = await withRetry(async () => {
148
+ const r = await fetch(this._getEndpoint(), {
149
+ method: "POST",
150
+ headers: this._buildHeaders(),
151
+ body: JSON.stringify(body),
152
+ });
153
+ if (!r.ok) {
154
+ const text = await r.text();
155
+ const err = new Error(`LLM API error ${r.status}: ${text}`);
156
+ err.status = r.status;
157
+ err.retryAfter = r.headers.get("retry-after");
158
+ throw err;
159
+ }
160
+ return r;
161
+ });
162
+
163
+ if (this.apiFormat === "anthropic") {
164
+ yield* this._parseAnthropicSSE(resp.body);
165
+ } else {
166
+ yield* this._parseOpenaiSSE(resp.body);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Non-streaming chat completion. Returns the full response
172
+ * normalized to OpenAI shape.
173
+ * @param {object} opts
174
+ * @returns {object}
61
175
  */
62
176
  async chat({ model, messages, maxTokens }) {
63
- const body = {
64
- model,
65
- messages,
66
- };
67
- if (maxTokens) body.max_tokens = maxTokens;
177
+ const body = this._buildNonStreamBody({ model, messages, maxTokens });
68
178
 
69
- const resp = await fetch(`${this.baseUrl}/chat/completions`, {
70
- method: "POST",
71
- headers: {
72
- "Authorization": `Bearer ${this.apiKey}`,
73
- "Content-Type": "application/json",
74
- },
75
- body: JSON.stringify(body),
179
+ const resp = await withRetry(async () => {
180
+ const r = await fetch(this._getEndpoint(), {
181
+ method: "POST",
182
+ headers: this._buildHeaders(),
183
+ body: JSON.stringify(body),
184
+ });
185
+ if (!r.ok) {
186
+ const text = await r.text();
187
+ const err = new Error(`LLM API error ${r.status}: ${text}`);
188
+ err.status = r.status;
189
+ err.retryAfter = r.headers.get("retry-after");
190
+ throw err;
191
+ }
192
+ return r;
76
193
  });
77
194
 
78
- if (!resp.ok) {
79
- const text = await resp.text();
80
- throw new Error(`LLM API error ${resp.status}: ${text}`);
195
+ const data = await resp.json();
196
+
197
+ if (this.apiFormat === "anthropic") {
198
+ // Normalize Anthropic response to OpenAI shape
199
+ const textParts = [];
200
+ for (const block of data.content || []) {
201
+ if (block.type === "text") textParts.push(block.text);
202
+ }
203
+ return {
204
+ choices: [{
205
+ message: {
206
+ role: "assistant",
207
+ content: textParts.join(""),
208
+ },
209
+ }],
210
+ usage: data.usage ? {
211
+ prompt_tokens: data.usage.input_tokens || 0,
212
+ completion_tokens: data.usage.output_tokens || 0,
213
+ } : undefined,
214
+ };
81
215
  }
82
216
 
83
- return resp.json();
217
+ return data;
84
218
  }
85
219
 
86
220
  /**
87
- * Parse SSE stream from a ReadableStream.
88
- * Handles the `data: {...}` format used by OpenAI-compatible APIs.
221
+ * List available models from the provider.
222
+ * @returns {Promise<Array<{id: string, name: string, ownedBy: string}>>}
223
+ */
224
+ async listModels() {
225
+ let endpoint;
226
+ if (this.apiFormat === "anthropic") {
227
+ endpoint = `${this.baseUrl}/v1/models`;
228
+ } else {
229
+ endpoint = `${this.baseUrl}/models`;
230
+ }
231
+
232
+ try {
233
+ const resp = await fetch(endpoint, {
234
+ method: "GET",
235
+ headers: this._buildHeaders(),
236
+ signal: AbortSignal.timeout(5000),
237
+ });
238
+
239
+ if (!resp.ok) return [];
240
+ const data = await resp.json();
241
+ return (data.data || []).map((m) => ({
242
+ id: m.id,
243
+ name: m.id,
244
+ ownedBy: m.owned_by || "",
245
+ }));
246
+ } catch {
247
+ return [];
248
+ }
249
+ }
250
+
251
+ // --- OpenAI SSE parsing ---
252
+
253
+ /**
254
+ * Parse SSE stream from OpenAI-compatible API.
89
255
  * @param {ReadableStream} body
90
- * @yields {object} Parsed JSON from each data line
256
+ * @yields {object} Parsed chunk
91
257
  */
92
- async *_parseSSE(body) {
258
+ async *_parseOpenaiSSE(body) {
93
259
  const decoder = new TextDecoder();
94
260
  let buffer = "";
95
261
 
96
262
  for await (const chunk of body) {
97
263
  buffer += decoder.decode(chunk, { stream: true });
98
264
  const lines = buffer.split("\n");
99
- buffer = lines.pop(); // keep incomplete line
265
+ buffer = lines.pop();
100
266
 
101
267
  for (const line of lines) {
102
268
  const trimmed = line.trim();
@@ -113,19 +279,146 @@ export class LLMClient {
113
279
  }
114
280
  }
115
281
 
116
- // Process any remaining buffer
117
282
  if (buffer.trim()) {
118
283
  const trimmed = buffer.trim();
119
284
  if (trimmed.startsWith("data: ")) {
120
285
  const data = trimmed.slice(6).trim();
121
286
  if (data !== "[DONE]") {
122
- try {
123
- yield JSON.parse(data);
124
- } catch {
125
- // Skip
287
+ try { yield JSON.parse(data); } catch { /* skip */ }
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ // --- Anthropic SSE parsing + normalization ---
294
+
295
+ /**
296
+ * Parse Anthropic SSE stream and normalize to OpenAI chunk shape.
297
+ * Anthropic SSE uses event types: message_start, content_block_start,
298
+ * content_block_delta, content_block_stop, message_delta, message_stop.
299
+ *
300
+ * Normalizes everything to: { choices: [{ delta: { content?, tool_calls? } }] }
301
+ * so engine.js needs no changes.
302
+ *
303
+ * @param {ReadableStream} body
304
+ * @yields {object} Normalized OpenAI-shaped chunk
305
+ */
306
+ async *_parseAnthropicSSE(body) {
307
+ const decoder = new TextDecoder();
308
+ let buffer = "";
309
+ let currentEventType = "";
310
+
311
+ // State for accumulating tool call content blocks
312
+ let toolCallIndex = -1;
313
+
314
+ for await (const rawChunk of body) {
315
+ buffer += decoder.decode(rawChunk, { stream: true });
316
+ const lines = buffer.split("\n");
317
+ buffer = lines.pop();
318
+
319
+ for (const line of lines) {
320
+ const trimmed = line.trim();
321
+ if (!trimmed || trimmed.startsWith(":")) continue;
322
+
323
+ if (trimmed.startsWith("event: ")) {
324
+ currentEventType = trimmed.slice(7).trim();
325
+ continue;
326
+ }
327
+
328
+ if (trimmed.startsWith("data: ")) {
329
+ const dataStr = trimmed.slice(6).trim();
330
+ let data;
331
+ try { data = JSON.parse(dataStr); } catch { continue; }
332
+
333
+ const normalized = this._normalizeAnthropicEvent(currentEventType, data, { toolCallIndex });
334
+ if (normalized) {
335
+ // Update tool call index tracking
336
+ if (normalized._newToolCallIndex !== undefined) {
337
+ toolCallIndex = normalized._newToolCallIndex;
338
+ delete normalized._newToolCallIndex;
339
+ }
340
+ yield normalized;
126
341
  }
127
342
  }
128
343
  }
129
344
  }
130
345
  }
346
+
347
+ /**
348
+ * Normalize a single Anthropic SSE event into OpenAI chunk shape.
349
+ * @param {string} eventType
350
+ * @param {object} data
351
+ * @param {object} state - Mutable state for tracking across events
352
+ * @returns {object|null} Normalized chunk or null if no output needed
353
+ */
354
+ _normalizeAnthropicEvent(eventType, data, state) {
355
+ switch (eventType) {
356
+ case "content_block_start": {
357
+ const block = data.content_block;
358
+ if (block?.type === "text") {
359
+ // Text block starting — no content yet
360
+ return null;
361
+ }
362
+ if (block?.type === "tool_use") {
363
+ state.toolCallIndex++;
364
+ const chunk = {
365
+ choices: [{
366
+ delta: {
367
+ tool_calls: [{
368
+ index: state.toolCallIndex,
369
+ id: block.id,
370
+ type: "function",
371
+ function: { name: block.name, arguments: "" },
372
+ }],
373
+ },
374
+ }],
375
+ _newToolCallIndex: state.toolCallIndex,
376
+ };
377
+ return chunk;
378
+ }
379
+ return null;
380
+ }
381
+
382
+ case "content_block_delta": {
383
+ const delta = data.delta;
384
+ if (delta?.type === "text_delta") {
385
+ return {
386
+ choices: [{ delta: { content: delta.text } }],
387
+ };
388
+ }
389
+ if (delta?.type === "input_json_delta") {
390
+ return {
391
+ choices: [{
392
+ delta: {
393
+ tool_calls: [{
394
+ index: state.toolCallIndex,
395
+ function: { arguments: delta.partial_json },
396
+ }],
397
+ },
398
+ }],
399
+ };
400
+ }
401
+ return null;
402
+ }
403
+
404
+ case "message_delta": {
405
+ // End of message — contains stop_reason and usage
406
+ return {
407
+ choices: [{
408
+ delta: {},
409
+ finish_reason: data.delta?.stop_reason === "end_turn" ? "stop" : (data.delta?.stop_reason || null),
410
+ }],
411
+ };
412
+ }
413
+
414
+ case "message_start":
415
+ case "content_block_stop":
416
+ case "message_stop":
417
+ case "ping":
418
+ return null;
419
+
420
+ default:
421
+ return null;
422
+ }
423
+ }
131
424
  }
@@ -11,4 +11,10 @@ export class Pipeline {
11
11
 
12
12
  /** Whether all requirements for leaving this phase are satisfied. */
13
13
  exitCriteriaMet() { throw new Error("Not implemented"); }
14
+
15
+ /** Serialize milestone state for persistence. Override in subclasses. */
16
+ exportState() { return {}; }
17
+
18
+ /** Restore milestone state from persisted data. Override in subclasses. */
19
+ importState(_data) { /* no-op by default */ }
14
20
  }
@@ -106,4 +106,22 @@ export class DistillationEngine extends Pipeline {
106
106
  if (!total) return false;
107
107
  return Object.keys(this.workflowsCreated).length >= total && this.workflowsPassing.length >= total;
108
108
  }
109
+
110
+ exportState() {
111
+ return {
112
+ skillsToDistill: this.skillsToDistill,
113
+ workflowsCreated: this.workflowsCreated,
114
+ workflowsTested: this.workflowsTested,
115
+ workflowsPassing: this.workflowsPassing,
116
+ tierAssignments: this.tierAssignments,
117
+ };
118
+ }
119
+
120
+ importState(data) {
121
+ if (Array.isArray(data.skillsToDistill) && data.skillsToDistill.length > this.skillsToDistill.length) this.skillsToDistill = data.skillsToDistill;
122
+ if (Array.isArray(data.workflowsPassing) && data.workflowsPassing.length > this.workflowsPassing.length) this.workflowsPassing = data.workflowsPassing;
123
+ if (data.workflowsCreated && typeof data.workflowsCreated === "object") Object.assign(this.workflowsCreated, data.workflowsCreated);
124
+ if (data.workflowsTested && typeof data.workflowsTested === "object") Object.assign(this.workflowsTested, data.workflowsTested);
125
+ if (data.tierAssignments && typeof data.tierAssignments === "object") Object.assign(this.tierAssignments, data.tierAssignments);
126
+ }
109
127
  }
@@ -87,4 +87,25 @@ export class RuleExtractionPipeline extends Pipeline {
87
87
  return this.regulationsScanned && this.rulesExtracted.length > 0 &&
88
88
  this.rulesWithTests.length >= Math.max(this.rulesExtracted.length * 0.8, 1) && this.coverageAudited;
89
89
  }
90
+
91
+ exportState() {
92
+ return {
93
+ regulationsScanned: this.regulationsScanned,
94
+ rulesExtracted: this.rulesExtracted,
95
+ rulesWithTests: this.rulesWithTests,
96
+ coverageAudited: this.coverageAudited,
97
+ };
98
+ }
99
+
100
+ importState(data) {
101
+ if (data.regulationsScanned) this.regulationsScanned = true;
102
+ if (data.coverageAudited) this.coverageAudited = true;
103
+ // Arrays: use imported as floor, then re-scan will reconcile
104
+ if (Array.isArray(data.rulesExtracted) && data.rulesExtracted.length > this.rulesExtracted.length) {
105
+ this.rulesExtracted = data.rulesExtracted;
106
+ }
107
+ if (Array.isArray(data.rulesWithTests) && data.rulesWithTests.length > this.rulesWithTests.length) {
108
+ this.rulesWithTests = data.rulesWithTests;
109
+ }
110
+ }
90
111
  }
@@ -11,9 +11,9 @@ const DEFAULT_ENV = `# === KC Agent Project Configuration ===
11
11
  # Language: en | zh
12
12
  LANGUAGE=en
13
13
 
14
- # === Worker LLM API (SiliconFlow) ===
15
- SILICONFLOW_API_KEY=
16
- SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1
14
+ # === LLM API ===
15
+ LLM_API_KEY=
16
+ LLM_BASE_URL=https://api.siliconflow.cn/v1
17
17
 
18
18
  # === Worker LLM Tiers (highest capability to lowest) ===
19
19
  TIER1=Pro/zai-org/GLM-5, Pro/moonshotai/Kimi-K2.5
@@ -53,8 +53,8 @@ export class ProjectInitializer extends Pipeline {
53
53
  if (!fs.existsSync(envPath)) {
54
54
  let envContent = DEFAULT_ENV;
55
55
  const gc = this._loadGlobalConfig();
56
- if (gc.api_key) envContent = envContent.replace("SILICONFLOW_API_KEY=", `SILICONFLOW_API_KEY=${gc.api_key}`);
57
- if (gc.base_url) envContent = envContent.replace("SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1", `SILICONFLOW_BASE_URL=${gc.base_url}`);
56
+ if (gc.api_key) envContent = envContent.replace("LLM_API_KEY=", `LLM_API_KEY=${gc.api_key}`);
57
+ if (gc.base_url) envContent = envContent.replace("LLM_BASE_URL=https://api.siliconflow.cn/v1", `LLM_BASE_URL=${gc.base_url}`);
58
58
  if (gc.accuracy_threshold) {
59
59
  envContent = envContent.replace("SKILL_ACCURACY=0.9", `SKILL_ACCURACY=${gc.accuracy_threshold}`);
60
60
  envContent = envContent.replace("WORKFLOW_ACCURACY=0.9", `WORKFLOW_ACCURACY=${gc.accuracy_threshold}`);
@@ -96,7 +96,7 @@ export class ProjectInitializer extends Pipeline {
96
96
  const envPath = path.join(this._workspace.cwd, ".env");
97
97
  if (fs.existsSync(envPath)) {
98
98
  for (const line of fs.readFileSync(envPath, "utf-8").split("\n")) {
99
- if (line.startsWith("SILICONFLOW_API_KEY=") && line.split("=")[1].trim()) {
99
+ if ((line.startsWith("LLM_API_KEY=") || line.startsWith("SILICONFLOW_API_KEY=")) && line.split("=")[1].trim()) {
100
100
  this.configReady = true; return;
101
101
  }
102
102
  }
@@ -154,4 +154,20 @@ export class ProjectInitializer extends Pipeline {
154
154
  exitCriteriaMet() {
155
155
  return this.workspaceCreated && this.configReady && this.hasRegulations && this.hasSamples;
156
156
  }
157
+
158
+ exportState() {
159
+ return {
160
+ workspaceCreated: this.workspaceCreated,
161
+ configReady: this.configReady,
162
+ hasRegulations: this.hasRegulations,
163
+ hasSamples: this.hasSamples,
164
+ };
165
+ }
166
+
167
+ importState(data) {
168
+ if (data.workspaceCreated) this.workspaceCreated = true;
169
+ if (data.configReady) this.configReady = true;
170
+ if (data.hasRegulations) this.hasRegulations = true;
171
+ if (data.hasSamples) this.hasSamples = true;
172
+ }
157
173
  }
@@ -94,4 +94,23 @@ export class ProductionQCPipeline extends Pipeline {
94
94
  }
95
95
 
96
96
  exitCriteriaMet() { return this.monitoringPhase === "stable"; }
97
+
98
+ exportState() {
99
+ return {
100
+ batchesProcessed: this.batchesProcessed,
101
+ totalDocuments: this.totalDocuments,
102
+ documentsReviewed: this.documentsReviewed,
103
+ monitoringPhase: this.monitoringPhase,
104
+ accuracyByRule: this.accuracyByRule,
105
+ issuesCount: this.issuesFound.length,
106
+ };
107
+ }
108
+
109
+ importState(data) {
110
+ if (typeof data.batchesProcessed === "number" && data.batchesProcessed > this.batchesProcessed) this.batchesProcessed = data.batchesProcessed;
111
+ if (typeof data.totalDocuments === "number" && data.totalDocuments > this.totalDocuments) this.totalDocuments = data.totalDocuments;
112
+ if (typeof data.documentsReviewed === "number" && data.documentsReviewed > this.documentsReviewed) this.documentsReviewed = data.documentsReviewed;
113
+ if (data.monitoringPhase) this.monitoringPhase = data.monitoringPhase;
114
+ if (data.accuracyByRule && typeof data.accuracyByRule === "object") Object.assign(this.accuracyByRule, data.accuracyByRule);
115
+ }
97
116
  }
@@ -77,4 +77,18 @@ export class SkillAuthoringPipeline extends Pipeline {
77
77
  if (!this.totalRules.length) return false;
78
78
  return this.skillsAuthored.length >= this.totalRules.length && this.skillsWithScripts.length >= this.skillsAuthored.length * 0.5;
79
79
  }
80
+
81
+ exportState() {
82
+ return {
83
+ totalRules: this.totalRules,
84
+ skillsAuthored: this.skillsAuthored,
85
+ skillsWithScripts: this.skillsWithScripts,
86
+ };
87
+ }
88
+
89
+ importState(data) {
90
+ if (Array.isArray(data.totalRules) && data.totalRules.length > this.totalRules.length) this.totalRules = data.totalRules;
91
+ if (Array.isArray(data.skillsAuthored) && data.skillsAuthored.length > this.skillsAuthored.length) this.skillsAuthored = data.skillsAuthored;
92
+ if (Array.isArray(data.skillsWithScripts) && data.skillsWithScripts.length > this.skillsWithScripts.length) this.skillsWithScripts = data.skillsWithScripts;
93
+ }
80
94
  }
@@ -106,4 +106,24 @@ export class SkillTestingPipeline extends Pipeline {
106
106
  if (!total) return false;
107
107
  return Object.keys(this.skillsTested).length >= total && this.skillsPassing.length >= total * this._accuracyThreshold;
108
108
  }
109
+
110
+ exportState() {
111
+ return {
112
+ skillsToTest: this.skillsToTest,
113
+ skillsTested: this.skillsTested,
114
+ skillsPassing: this.skillsPassing,
115
+ iterationCount: this.iterationCount,
116
+ };
117
+ }
118
+
119
+ importState(data) {
120
+ if (typeof data.iterationCount === "number" && data.iterationCount > this.iterationCount) this.iterationCount = data.iterationCount;
121
+ if (Array.isArray(data.skillsToTest) && data.skillsToTest.length > this.skillsToTest.length) this.skillsToTest = data.skillsToTest;
122
+ if (Array.isArray(data.skillsPassing) && data.skillsPassing.length > this.skillsPassing.length) this.skillsPassing = data.skillsPassing;
123
+ if (data.skillsTested && typeof data.skillsTested === "object") {
124
+ for (const [k, v] of Object.entries(data.skillsTested)) {
125
+ if (!this.skillsTested[k] || v > this.skillsTested[k]) this.skillsTested[k] = v;
126
+ }
127
+ }
128
+ }
109
129
  }