@telepat/snoopy 0.1.13 → 0.1.15
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/README.md +70 -215
- package/README.zh-CN.md +137 -0
- package/dist/src/agent/install.d.ts +18 -0
- package/dist/src/agent/install.js +488 -0
- package/dist/src/cli/commands/feedback.d.ts +18 -0
- package/dist/src/cli/commands/feedback.js +276 -0
- package/dist/src/cli/commands/prompt.d.ts +6 -0
- package/dist/src/cli/commands/prompt.js +92 -0
- package/dist/src/cli/commands/promptEditor.d.ts +1 -0
- package/dist/src/cli/commands/promptEditor.js +17 -0
- package/dist/src/cli/flows/jobAddFlow.js +1 -1
- package/dist/src/cli/index.js +86 -1
- package/dist/src/mcp/helpers.d.ts +46 -0
- package/dist/src/mcp/helpers.js +506 -0
- package/dist/src/mcp/server.d.ts +1 -0
- package/dist/src/mcp/server.js +299 -0
- package/dist/src/mcp/tools.d.ts +90 -0
- package/dist/src/mcp/tools.js +106 -0
- package/dist/src/services/db/migrations/002_feedback_fields.d.ts +7 -0
- package/dist/src/services/db/migrations/002_feedback_fields.js +22 -0
- package/dist/src/services/db/migrations/index.js +2 -1
- package/dist/src/services/db/repositories/jobsRepo.d.ts +2 -0
- package/dist/src/services/db/repositories/jobsRepo.js +15 -0
- package/dist/src/services/db/repositories/scanItemsRepo.d.ts +17 -0
- package/dist/src/services/db/repositories/scanItemsRepo.js +197 -2
- package/dist/src/services/feedback/consolidationService.d.ts +28 -0
- package/dist/src/services/feedback/consolidationService.js +124 -0
- package/dist/src/services/openrouter/client.d.ts +23 -0
- package/dist/src/services/openrouter/client.js +67 -0
- package/dist/src/types/settings.d.ts +1 -1
- package/dist/src/types/settings.js +1 -1
- package/dist/src/ui/components/MultilinePrompt.d.ts +10 -0
- package/dist/src/ui/components/MultilinePrompt.js +87 -0
- package/dist/src/ui/components/multilinePromptModel.d.ts +25 -0
- package/dist/src/ui/components/multilinePromptModel.js +76 -0
- package/package.json +4 -1
|
@@ -31,6 +31,8 @@ export class ScanItemsRepository {
|
|
|
31
31
|
qualified: row.qualified === 1,
|
|
32
32
|
viewed: row.viewed === 1,
|
|
33
33
|
validated: row.validated === 1,
|
|
34
|
+
isValid: row.isValid === 1,
|
|
35
|
+
feedbackConsolidated: row.feedbackConsolidated === 1,
|
|
34
36
|
processed: row.processed === 1,
|
|
35
37
|
consumed: row.consumed === 1
|
|
36
38
|
}));
|
|
@@ -63,6 +65,8 @@ export class ScanItemsRepository {
|
|
|
63
65
|
id,
|
|
64
66
|
job_id as jobId,
|
|
65
67
|
run_id as runId,
|
|
68
|
+
type,
|
|
69
|
+
subreddit,
|
|
66
70
|
author,
|
|
67
71
|
title,
|
|
68
72
|
body,
|
|
@@ -70,6 +74,9 @@ export class ScanItemsRepository {
|
|
|
70
74
|
reddit_posted_at as redditPostedAt,
|
|
71
75
|
viewed,
|
|
72
76
|
validated,
|
|
77
|
+
is_valid as isValid,
|
|
78
|
+
is_valid_reason as isValidReason,
|
|
79
|
+
feedback_consolidated as feedbackConsolidated,
|
|
73
80
|
processed,
|
|
74
81
|
consumed,
|
|
75
82
|
qualification_reason as qualificationReason,
|
|
@@ -84,6 +91,8 @@ export class ScanItemsRepository {
|
|
|
84
91
|
...row,
|
|
85
92
|
viewed: row.viewed === 1,
|
|
86
93
|
validated: row.validated === 1,
|
|
94
|
+
isValid: row.isValid === 1,
|
|
95
|
+
feedbackConsolidated: row.feedbackConsolidated === 1,
|
|
87
96
|
processed: row.processed === 1,
|
|
88
97
|
consumed: row.consumed === 1
|
|
89
98
|
}));
|
|
@@ -95,6 +104,8 @@ export class ScanItemsRepository {
|
|
|
95
104
|
id,
|
|
96
105
|
job_id as jobId,
|
|
97
106
|
run_id as runId,
|
|
107
|
+
type,
|
|
108
|
+
subreddit,
|
|
98
109
|
author,
|
|
99
110
|
title,
|
|
100
111
|
body,
|
|
@@ -102,6 +113,9 @@ export class ScanItemsRepository {
|
|
|
102
113
|
reddit_posted_at as redditPostedAt,
|
|
103
114
|
viewed,
|
|
104
115
|
validated,
|
|
116
|
+
is_valid as isValid,
|
|
117
|
+
is_valid_reason as isValidReason,
|
|
118
|
+
feedback_consolidated as feedbackConsolidated,
|
|
105
119
|
processed,
|
|
106
120
|
consumed,
|
|
107
121
|
qualification_reason as qualificationReason,
|
|
@@ -117,6 +131,8 @@ export class ScanItemsRepository {
|
|
|
117
131
|
...row,
|
|
118
132
|
viewed: row.viewed === 1,
|
|
119
133
|
validated: row.validated === 1,
|
|
134
|
+
isValid: row.isValid === 1,
|
|
135
|
+
feedbackConsolidated: row.feedbackConsolidated === 1,
|
|
120
136
|
processed: row.processed === 1,
|
|
121
137
|
consumed: row.consumed === 1
|
|
122
138
|
}));
|
|
@@ -139,6 +155,9 @@ export class ScanItemsRepository {
|
|
|
139
155
|
qualified,
|
|
140
156
|
viewed,
|
|
141
157
|
validated,
|
|
158
|
+
is_valid as isValid,
|
|
159
|
+
is_valid_reason as isValidReason,
|
|
160
|
+
feedback_consolidated as feedbackConsolidated,
|
|
142
161
|
processed,
|
|
143
162
|
consumed,
|
|
144
163
|
qualification_reason as qualificationReason,
|
|
@@ -180,6 +199,9 @@ export class ScanItemsRepository {
|
|
|
180
199
|
qualified,
|
|
181
200
|
viewed,
|
|
182
201
|
validated,
|
|
202
|
+
is_valid as isValid,
|
|
203
|
+
is_valid_reason as isValidReason,
|
|
204
|
+
feedback_consolidated as feedbackConsolidated,
|
|
183
205
|
processed,
|
|
184
206
|
consumed,
|
|
185
207
|
qualification_reason as qualificationReason,
|
|
@@ -298,6 +320,8 @@ export class ScanItemsRepository {
|
|
|
298
320
|
id,
|
|
299
321
|
job_id as jobId,
|
|
300
322
|
run_id as runId,
|
|
323
|
+
type,
|
|
324
|
+
subreddit,
|
|
301
325
|
author,
|
|
302
326
|
title,
|
|
303
327
|
body,
|
|
@@ -305,6 +329,9 @@ export class ScanItemsRepository {
|
|
|
305
329
|
reddit_posted_at as redditPostedAt,
|
|
306
330
|
viewed,
|
|
307
331
|
validated,
|
|
332
|
+
is_valid as isValid,
|
|
333
|
+
is_valid_reason as isValidReason,
|
|
334
|
+
feedback_consolidated as feedbackConsolidated,
|
|
308
335
|
processed,
|
|
309
336
|
consumed,
|
|
310
337
|
qualification_reason as qualificationReason,
|
|
@@ -326,10 +353,175 @@ export class ScanItemsRepository {
|
|
|
326
353
|
...row,
|
|
327
354
|
viewed: row.viewed === 1,
|
|
328
355
|
validated: row.validated === 1,
|
|
356
|
+
isValid: row.isValid === 1,
|
|
357
|
+
feedbackConsolidated: row.feedbackConsolidated === 1,
|
|
329
358
|
processed: row.processed === 1,
|
|
330
359
|
consumed: row.consumed === 1
|
|
331
360
|
}));
|
|
332
361
|
}
|
|
362
|
+
getQualifiedById(id) {
|
|
363
|
+
const rows = this.db
|
|
364
|
+
.prepare(`SELECT
|
|
365
|
+
id,
|
|
366
|
+
job_id as jobId,
|
|
367
|
+
run_id as runId,
|
|
368
|
+
type,
|
|
369
|
+
subreddit,
|
|
370
|
+
author,
|
|
371
|
+
title,
|
|
372
|
+
body,
|
|
373
|
+
url,
|
|
374
|
+
reddit_posted_at as redditPostedAt,
|
|
375
|
+
viewed,
|
|
376
|
+
validated,
|
|
377
|
+
is_valid as isValid,
|
|
378
|
+
is_valid_reason as isValidReason,
|
|
379
|
+
feedback_consolidated as feedbackConsolidated,
|
|
380
|
+
processed,
|
|
381
|
+
consumed,
|
|
382
|
+
qualification_reason as qualificationReason,
|
|
383
|
+
created_at as createdAt
|
|
384
|
+
FROM scan_items
|
|
385
|
+
WHERE id = ?
|
|
386
|
+
AND qualified = 1
|
|
387
|
+
LIMIT 1`)
|
|
388
|
+
.all(id);
|
|
389
|
+
const row = rows[0];
|
|
390
|
+
if (!row) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
...row,
|
|
395
|
+
viewed: row.viewed === 1,
|
|
396
|
+
validated: row.validated === 1,
|
|
397
|
+
isValid: row.isValid === 1,
|
|
398
|
+
feedbackConsolidated: row.feedbackConsolidated === 1,
|
|
399
|
+
processed: row.processed === 1,
|
|
400
|
+
consumed: row.consumed === 1
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
listUnvalidatedQualified(jobId, limit = 10) {
|
|
404
|
+
const boundedLimit = Math.max(1, Math.floor(limit));
|
|
405
|
+
const rows = this.db
|
|
406
|
+
.prepare(`SELECT
|
|
407
|
+
id,
|
|
408
|
+
job_id as jobId,
|
|
409
|
+
run_id as runId,
|
|
410
|
+
type,
|
|
411
|
+
subreddit,
|
|
412
|
+
author,
|
|
413
|
+
title,
|
|
414
|
+
body,
|
|
415
|
+
url,
|
|
416
|
+
reddit_posted_at as redditPostedAt,
|
|
417
|
+
viewed,
|
|
418
|
+
validated,
|
|
419
|
+
is_valid as isValid,
|
|
420
|
+
is_valid_reason as isValidReason,
|
|
421
|
+
feedback_consolidated as feedbackConsolidated,
|
|
422
|
+
processed,
|
|
423
|
+
consumed,
|
|
424
|
+
qualification_reason as qualificationReason,
|
|
425
|
+
created_at as createdAt
|
|
426
|
+
FROM scan_items
|
|
427
|
+
WHERE qualified = 1
|
|
428
|
+
AND validated = 0
|
|
429
|
+
${jobId ? 'AND job_id = ?' : ''}
|
|
430
|
+
ORDER BY datetime(created_at) DESC, id DESC
|
|
431
|
+
LIMIT ?`)
|
|
432
|
+
.all(...(jobId ? [jobId, boundedLimit] : [boundedLimit]));
|
|
433
|
+
return rows.map((row) => ({
|
|
434
|
+
...row,
|
|
435
|
+
viewed: row.viewed === 1,
|
|
436
|
+
validated: row.validated === 1,
|
|
437
|
+
isValid: row.isValid === 1,
|
|
438
|
+
feedbackConsolidated: row.feedbackConsolidated === 1,
|
|
439
|
+
processed: row.processed === 1,
|
|
440
|
+
consumed: row.consumed === 1
|
|
441
|
+
}));
|
|
442
|
+
}
|
|
443
|
+
submitFeedback(resultId, isValid, reason) {
|
|
444
|
+
const normalizedReason = reason?.trim() ?? null;
|
|
445
|
+
const result = this.db
|
|
446
|
+
.prepare(`UPDATE scan_items
|
|
447
|
+
SET validated = 1,
|
|
448
|
+
is_valid = ?,
|
|
449
|
+
is_valid_reason = ?,
|
|
450
|
+
feedback_consolidated = 0
|
|
451
|
+
WHERE id = ?
|
|
452
|
+
AND qualified = 1`)
|
|
453
|
+
.run(isValid ? 1 : 0, normalizedReason, resultId);
|
|
454
|
+
return Number(result.changes) > 0;
|
|
455
|
+
}
|
|
456
|
+
listPendingFeedbackConsolidation(jobId, limit) {
|
|
457
|
+
const hasLimit = limit !== undefined && limit !== null;
|
|
458
|
+
const boundedLimit = hasLimit ? Math.max(1, Math.floor(limit)) : undefined;
|
|
459
|
+
let query = `SELECT
|
|
460
|
+
id,
|
|
461
|
+
job_id as jobId,
|
|
462
|
+
run_id as runId,
|
|
463
|
+
type,
|
|
464
|
+
subreddit,
|
|
465
|
+
author,
|
|
466
|
+
title,
|
|
467
|
+
body,
|
|
468
|
+
url,
|
|
469
|
+
reddit_posted_at as redditPostedAt,
|
|
470
|
+
viewed,
|
|
471
|
+
validated,
|
|
472
|
+
is_valid as isValid,
|
|
473
|
+
is_valid_reason as isValidReason,
|
|
474
|
+
feedback_consolidated as feedbackConsolidated,
|
|
475
|
+
processed,
|
|
476
|
+
consumed,
|
|
477
|
+
qualification_reason as qualificationReason,
|
|
478
|
+
created_at as createdAt
|
|
479
|
+
FROM scan_items
|
|
480
|
+
WHERE qualified = 1
|
|
481
|
+
AND validated = 1
|
|
482
|
+
AND feedback_consolidated = 0`;
|
|
483
|
+
const params = [];
|
|
484
|
+
if (jobId) {
|
|
485
|
+
query += ' AND job_id = ?';
|
|
486
|
+
params.push(jobId);
|
|
487
|
+
}
|
|
488
|
+
query += ' ORDER BY datetime(created_at) DESC, id DESC';
|
|
489
|
+
if (boundedLimit !== undefined) {
|
|
490
|
+
query += ' LIMIT ?';
|
|
491
|
+
params.push(boundedLimit);
|
|
492
|
+
}
|
|
493
|
+
const rows = this.db.prepare(query).all(...params);
|
|
494
|
+
return rows.map((row) => ({
|
|
495
|
+
...row,
|
|
496
|
+
viewed: row.viewed === 1,
|
|
497
|
+
validated: row.validated === 1,
|
|
498
|
+
isValid: row.isValid === 1,
|
|
499
|
+
feedbackConsolidated: row.feedbackConsolidated === 1,
|
|
500
|
+
processed: row.processed === 1,
|
|
501
|
+
consumed: row.consumed === 1
|
|
502
|
+
}));
|
|
503
|
+
}
|
|
504
|
+
countPendingFeedbackConsolidation(jobId) {
|
|
505
|
+
const row = this.db
|
|
506
|
+
.prepare(`SELECT COUNT(*) as count
|
|
507
|
+
FROM scan_items
|
|
508
|
+
WHERE qualified = 1
|
|
509
|
+
AND validated = 1
|
|
510
|
+
AND feedback_consolidated = 0
|
|
511
|
+
${jobId ? 'AND job_id = ?' : ''}`)
|
|
512
|
+
.get(...(jobId ? [jobId] : []));
|
|
513
|
+
return Number(row?.count ?? 0);
|
|
514
|
+
}
|
|
515
|
+
markFeedbackConsolidated(ids) {
|
|
516
|
+
if (ids.length === 0) {
|
|
517
|
+
return 0;
|
|
518
|
+
}
|
|
519
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
520
|
+
const result = this.db
|
|
521
|
+
.prepare(`UPDATE scan_items SET feedback_consolidated = 1 WHERE id IN (${placeholders})`)
|
|
522
|
+
.run(...ids);
|
|
523
|
+
return Number(result.changes);
|
|
524
|
+
}
|
|
333
525
|
markConsumed(ids) {
|
|
334
526
|
if (ids.length === 0) {
|
|
335
527
|
return 0;
|
|
@@ -360,6 +552,9 @@ export class ScanItemsRepository {
|
|
|
360
552
|
qualified,
|
|
361
553
|
viewed,
|
|
362
554
|
validated,
|
|
555
|
+
is_valid,
|
|
556
|
+
is_valid_reason,
|
|
557
|
+
feedback_consolidated,
|
|
363
558
|
processed,
|
|
364
559
|
consumed,
|
|
365
560
|
prompt_tokens,
|
|
@@ -367,8 +562,8 @@ export class ScanItemsRepository {
|
|
|
367
562
|
estimated_cost_usd,
|
|
368
563
|
qualification_reason,
|
|
369
564
|
created_at
|
|
370
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`)
|
|
371
|
-
.run(newId, newItem.jobId, newItem.runId, newItem.type, newItem.redditPostId, newItem.redditCommentId, newItem.subreddit, newItem.author, newItem.title, newItem.body, newItem.url, newItem.redditPostedAt, newItem.qualified ? 1 : 0, newItem.viewed ? 1 : 0, newItem.validated ? 1 : 0, newItem.processed ? 1 : 0, newItem.consumed ? 1 : 0, newItem.promptTokens ?? 0, newItem.completionTokens ?? 0, newItem.estimatedCostUsd ?? null, newItem.qualificationReason);
|
|
565
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`)
|
|
566
|
+
.run(newId, newItem.jobId, newItem.runId, newItem.type, newItem.redditPostId, newItem.redditCommentId, newItem.subreddit, newItem.author, newItem.title, newItem.body, newItem.url, newItem.redditPostedAt, newItem.qualified ? 1 : 0, newItem.viewed ? 1 : 0, newItem.validated ? 1 : 0, newItem.isValid ? 1 : 0, newItem.isValidReason ?? null, newItem.feedbackConsolidated ? 1 : 0, newItem.processed ? 1 : 0, newItem.consumed ? 1 : 0, newItem.promptTokens ?? 0, newItem.completionTokens ?? 0, newItem.estimatedCostUsd ?? null, newItem.qualificationReason);
|
|
372
567
|
if (insertResult.changes === 0) {
|
|
373
568
|
const existingId = this.findExistingScanItemId(newItem.jobId, newItem.redditPostId, newItem.redditCommentId);
|
|
374
569
|
return {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface ConsolidateFeedbackOptions {
|
|
2
|
+
jobRef?: string;
|
|
3
|
+
limit?: number;
|
|
4
|
+
}
|
|
5
|
+
export interface ConsolidateFeedbackJobResult {
|
|
6
|
+
jobId: string;
|
|
7
|
+
jobSlug: string;
|
|
8
|
+
jobName: string;
|
|
9
|
+
pendingCount: number;
|
|
10
|
+
consolidatedCount: number;
|
|
11
|
+
promptUpdated: boolean;
|
|
12
|
+
oldPrompt?: string;
|
|
13
|
+
newPrompt?: string;
|
|
14
|
+
changeSummary?: string[];
|
|
15
|
+
rationale?: string;
|
|
16
|
+
promptTokens?: number;
|
|
17
|
+
completionTokens?: number;
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ConsolidateFeedbackResult {
|
|
21
|
+
jobRef?: string;
|
|
22
|
+
totalPendingBefore: number;
|
|
23
|
+
totalPendingAfter: number;
|
|
24
|
+
totalConsolidated: number;
|
|
25
|
+
requiresConsolidation: boolean;
|
|
26
|
+
jobs: ConsolidateFeedbackJobResult[];
|
|
27
|
+
}
|
|
28
|
+
export declare function consolidateFeedback(options?: ConsolidateFeedbackOptions): Promise<ConsolidateFeedbackResult>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { JobsRepository } from '../db/repositories/jobsRepo.js';
|
|
2
|
+
import { ScanItemsRepository } from '../db/repositories/scanItemsRepo.js';
|
|
3
|
+
import { SettingsRepository } from '../db/repositories/settingsRepo.js';
|
|
4
|
+
import { getOpenRouterApiKey } from '../security/secretStore.js';
|
|
5
|
+
import { OpenRouterClient, } from '../openrouter/client.js';
|
|
6
|
+
function toFeedbackItem(row) {
|
|
7
|
+
return {
|
|
8
|
+
id: row.id,
|
|
9
|
+
type: row.type,
|
|
10
|
+
subreddit: row.subreddit,
|
|
11
|
+
title: row.title,
|
|
12
|
+
body: row.body,
|
|
13
|
+
qualificationReason: row.qualificationReason,
|
|
14
|
+
userIsValid: row.isValid,
|
|
15
|
+
userReason: row.isValidReason,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function toJobPromptResult(result) {
|
|
19
|
+
return {
|
|
20
|
+
changeSummary: result.changeSummary,
|
|
21
|
+
rationale: result.rationale,
|
|
22
|
+
promptTokens: result.promptTokens,
|
|
23
|
+
completionTokens: result.completionTokens,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export async function consolidateFeedback(options = {}) {
|
|
27
|
+
const jobsRepo = new JobsRepository();
|
|
28
|
+
const scanItemsRepo = new ScanItemsRepository();
|
|
29
|
+
let jobId;
|
|
30
|
+
if (options.jobRef) {
|
|
31
|
+
const job = jobsRepo.getByRef(options.jobRef);
|
|
32
|
+
if (!job) {
|
|
33
|
+
throw new Error(`Job not found: ${options.jobRef}`);
|
|
34
|
+
}
|
|
35
|
+
jobId = job.id;
|
|
36
|
+
}
|
|
37
|
+
const pendingRows = scanItemsRepo.listPendingFeedbackConsolidation(jobId, options.limit);
|
|
38
|
+
const totalPendingBefore = pendingRows.length;
|
|
39
|
+
if (totalPendingBefore === 0) {
|
|
40
|
+
return {
|
|
41
|
+
jobRef: options.jobRef,
|
|
42
|
+
totalPendingBefore: 0,
|
|
43
|
+
totalPendingAfter: 0,
|
|
44
|
+
totalConsolidated: 0,
|
|
45
|
+
requiresConsolidation: false,
|
|
46
|
+
jobs: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const apiKey = await getOpenRouterApiKey();
|
|
50
|
+
if (!apiKey) {
|
|
51
|
+
throw new Error('OpenRouter API key not configured. Run snoopy settings to configure it first.');
|
|
52
|
+
}
|
|
53
|
+
const settingsRepo = new SettingsRepository();
|
|
54
|
+
const appSettings = settingsRepo.getAppSettings();
|
|
55
|
+
const openRouterClient = new OpenRouterClient(apiKey);
|
|
56
|
+
const groupedByJob = new Map();
|
|
57
|
+
for (const row of pendingRows) {
|
|
58
|
+
const existing = groupedByJob.get(row.jobId) ?? [];
|
|
59
|
+
existing.push(row);
|
|
60
|
+
groupedByJob.set(row.jobId, existing);
|
|
61
|
+
}
|
|
62
|
+
const jobResults = [];
|
|
63
|
+
let totalConsolidated = 0;
|
|
64
|
+
for (const [currentJobId, rows] of groupedByJob) {
|
|
65
|
+
const job = jobsRepo.getById(currentJobId);
|
|
66
|
+
if (!job) {
|
|
67
|
+
jobResults.push({
|
|
68
|
+
jobId: currentJobId,
|
|
69
|
+
jobSlug: 'unknown',
|
|
70
|
+
jobName: 'Unknown job',
|
|
71
|
+
pendingCount: rows.length,
|
|
72
|
+
consolidatedCount: 0,
|
|
73
|
+
promptUpdated: false,
|
|
74
|
+
error: `Job no longer exists: ${currentJobId}`,
|
|
75
|
+
});
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const oldPrompt = job.qualificationPrompt;
|
|
80
|
+
const result = await openRouterClient.consolidateQualificationPrompt({
|
|
81
|
+
model: appSettings.model,
|
|
82
|
+
modelSettings: appSettings.modelSettings,
|
|
83
|
+
currentQualificationPrompt: oldPrompt,
|
|
84
|
+
feedbackItems: rows.map(toFeedbackItem),
|
|
85
|
+
});
|
|
86
|
+
const newPrompt = result.revisedQualificationPrompt;
|
|
87
|
+
jobsRepo.updateQualificationPromptById(job.id, newPrompt);
|
|
88
|
+
const consolidatedCount = scanItemsRepo.markFeedbackConsolidated(rows.map((row) => row.id));
|
|
89
|
+
totalConsolidated += consolidatedCount;
|
|
90
|
+
jobResults.push({
|
|
91
|
+
jobId: job.id,
|
|
92
|
+
jobSlug: job.slug,
|
|
93
|
+
jobName: job.name,
|
|
94
|
+
pendingCount: rows.length,
|
|
95
|
+
consolidatedCount,
|
|
96
|
+
promptUpdated: true,
|
|
97
|
+
oldPrompt,
|
|
98
|
+
newPrompt,
|
|
99
|
+
...toJobPromptResult(result),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
jobResults.push({
|
|
104
|
+
jobId: job.id,
|
|
105
|
+
jobSlug: job.slug,
|
|
106
|
+
jobName: job.name,
|
|
107
|
+
pendingCount: rows.length,
|
|
108
|
+
consolidatedCount: 0,
|
|
109
|
+
promptUpdated: false,
|
|
110
|
+
error: error instanceof Error ? error.message : String(error),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const totalPendingAfter = scanItemsRepo.countPendingFeedbackConsolidation(jobId);
|
|
115
|
+
return {
|
|
116
|
+
jobRef: options.jobRef,
|
|
117
|
+
totalPendingBefore,
|
|
118
|
+
totalPendingAfter,
|
|
119
|
+
totalConsolidated,
|
|
120
|
+
requiresConsolidation: totalPendingAfter > 0,
|
|
121
|
+
jobs: jobResults,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=consolidationService.js.map
|
|
@@ -7,6 +7,23 @@ export interface QualificationResult {
|
|
|
7
7
|
promptTokens: number;
|
|
8
8
|
completionTokens: number;
|
|
9
9
|
}
|
|
10
|
+
export interface ConsolidationFeedbackItem {
|
|
11
|
+
id: string;
|
|
12
|
+
type: 'post' | 'comment';
|
|
13
|
+
subreddit: string;
|
|
14
|
+
title: string | null;
|
|
15
|
+
body: string;
|
|
16
|
+
qualificationReason: string | null;
|
|
17
|
+
userIsValid: boolean;
|
|
18
|
+
userReason: string | null;
|
|
19
|
+
}
|
|
20
|
+
export interface ConsolidatedPromptResult {
|
|
21
|
+
revisedQualificationPrompt: string;
|
|
22
|
+
changeSummary: string[];
|
|
23
|
+
rationale: string;
|
|
24
|
+
promptTokens: number;
|
|
25
|
+
completionTokens: number;
|
|
26
|
+
}
|
|
10
27
|
export interface OpenRouterTraceHooks {
|
|
11
28
|
onRequest?: (operation: string, payload: unknown) => void;
|
|
12
29
|
onResponse?: (operation: string, payload: unknown) => void;
|
|
@@ -51,6 +68,12 @@ export declare class OpenRouterClient {
|
|
|
51
68
|
question: string;
|
|
52
69
|
answer: string;
|
|
53
70
|
}>, model: string): Promise<GeneratedJobSpec>;
|
|
71
|
+
consolidateQualificationPrompt(input: {
|
|
72
|
+
model: string;
|
|
73
|
+
modelSettings: ModelSettings;
|
|
74
|
+
currentQualificationPrompt: string;
|
|
75
|
+
feedbackItems: ConsolidationFeedbackItem[];
|
|
76
|
+
}): Promise<ConsolidatedPromptResult>;
|
|
54
77
|
qualifyPost(input: QualifyPostRequest): Promise<QualificationResult>;
|
|
55
78
|
qualifyCommentThread(input: QualifyCommentThreadRequest): Promise<QualificationResult>;
|
|
56
79
|
private runQualification;
|
|
@@ -16,6 +16,11 @@ const qualifySchema = z.object({
|
|
|
16
16
|
qualified: z.boolean(),
|
|
17
17
|
reason: z.string().min(1).max(80)
|
|
18
18
|
});
|
|
19
|
+
const consolidatePromptSchema = z.object({
|
|
20
|
+
revisedQualificationPrompt: z.string().min(8),
|
|
21
|
+
changeSummary: z.array(z.string().min(1)).max(10),
|
|
22
|
+
rationale: z.string().min(1)
|
|
23
|
+
});
|
|
19
24
|
function toAlphaLabel(index) {
|
|
20
25
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
21
26
|
let value = index;
|
|
@@ -241,6 +246,68 @@ export class OpenRouterClient {
|
|
|
241
246
|
throw toOpenRouterError(error);
|
|
242
247
|
}
|
|
243
248
|
}
|
|
249
|
+
async consolidateQualificationPrompt(input) {
|
|
250
|
+
const feedbackJson = JSON.stringify(input.feedbackItems, null, 2);
|
|
251
|
+
const userMessage = [
|
|
252
|
+
'Current qualification prompt:',
|
|
253
|
+
input.currentQualificationPrompt,
|
|
254
|
+
'',
|
|
255
|
+
'User feedback examples (JSON):',
|
|
256
|
+
feedbackJson,
|
|
257
|
+
'',
|
|
258
|
+
'Revise by extracting general, reusable rules from feedback patterns.',
|
|
259
|
+
'Do not optimize for any single example or subreddit-specific edge case unless repeatedly supported.',
|
|
260
|
+
'Prefer consolidating with existing lines over adding many new lines.',
|
|
261
|
+
'Add a new line only when it represents a clearly distinct rule.',
|
|
262
|
+
'Return JSON with keys: revisedQualificationPrompt, changeSummary, rationale.'
|
|
263
|
+
].join('\n');
|
|
264
|
+
const systemMessage = [
|
|
265
|
+
'You improve qualification prompts using user feedback.',
|
|
266
|
+
'Goal: make minimal but meaningful edits that improve classification quality over time.',
|
|
267
|
+
'Primary behavior: infer generally applicable decision rules, not one-off fixes.',
|
|
268
|
+
'Rules:',
|
|
269
|
+
'- Keep the revised prompt concise and practical; remove redundancy where possible.',
|
|
270
|
+
'- Generalize from repeated patterns in feedback rather than specific single examples.',
|
|
271
|
+
'- Avoid overfitting to named entities, exact wording, or one result unless it represents a broader rule.',
|
|
272
|
+
'- Preserve existing intent unless feedback clearly indicates a problem.',
|
|
273
|
+
'- Consolidate edits into existing points when semantically compatible; do not append endlessly.',
|
|
274
|
+
'- Introduce new lines only for genuinely new criteria not already covered.',
|
|
275
|
+
'- Incorporate invalid-case reasons as clear disqualifiers when they reflect reusable logic.',
|
|
276
|
+
'- Do not mention JSON, tooling, or implementation details in the revised prompt.',
|
|
277
|
+
'- Return valid JSON only with: revisedQualificationPrompt, changeSummary, rationale.'
|
|
278
|
+
].join('\n');
|
|
279
|
+
try {
|
|
280
|
+
const payload = {
|
|
281
|
+
model: input.model,
|
|
282
|
+
temperature: input.modelSettings.temperature,
|
|
283
|
+
top_p: input.modelSettings.topP,
|
|
284
|
+
max_tokens: Math.max(600, input.modelSettings.maxTokens),
|
|
285
|
+
response_format: {
|
|
286
|
+
type: 'json_object'
|
|
287
|
+
},
|
|
288
|
+
messages: [
|
|
289
|
+
{ role: 'system', content: systemMessage },
|
|
290
|
+
{ role: 'user', content: userMessage }
|
|
291
|
+
]
|
|
292
|
+
};
|
|
293
|
+
this.traceRequest('chat.completions.create.consolidate_prompt', payload);
|
|
294
|
+
const completion = (await this.client.chat.completions.create(payload));
|
|
295
|
+
this.traceResponse('chat.completions.create.consolidate_prompt', completion);
|
|
296
|
+
const raw = completion.choices?.[0]?.message?.content ?? '';
|
|
297
|
+
const parsed = consolidatePromptSchema.parse(parseStructuredJson(raw));
|
|
298
|
+
return {
|
|
299
|
+
revisedQualificationPrompt: parsed.revisedQualificationPrompt,
|
|
300
|
+
changeSummary: parsed.changeSummary,
|
|
301
|
+
rationale: parsed.rationale,
|
|
302
|
+
promptTokens: completion.usage?.prompt_tokens ?? 0,
|
|
303
|
+
completionTokens: completion.usage?.completion_tokens ?? 0
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
this.traceError('chat.completions.create.consolidate_prompt', error);
|
|
308
|
+
throw toOpenRouterError(error);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
244
311
|
async qualifyPost(input) {
|
|
245
312
|
const userMessage = [`Post title: ${input.postTitle}`, '', `Post body: ${input.postBody}`].join('\n');
|
|
246
313
|
return this.runQualification(input, userMessage);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const DEFAULT_MODEL = "
|
|
1
|
+
export declare const DEFAULT_MODEL = "deepseek/deepseek-v4-pro";
|
|
2
2
|
export declare const DEFAULT_CRON_INTERVAL_MINUTES = 30;
|
|
3
3
|
export declare const DEFAULT_JOB_TIMEOUT_MS: number;
|
|
4
4
|
export declare function intervalToCron(minutes: number): string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const DEFAULT_MODEL = '
|
|
1
|
+
export const DEFAULT_MODEL = 'deepseek/deepseek-v4-pro';
|
|
2
2
|
export const DEFAULT_CRON_INTERVAL_MINUTES = 30;
|
|
3
3
|
export const DEFAULT_JOB_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
4
4
|
export function intervalToCron(minutes) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface MultilinePromptProps {
|
|
3
|
+
label: string;
|
|
4
|
+
initialValue?: string;
|
|
5
|
+
rows?: number;
|
|
6
|
+
onSubmit: (value: string) => void;
|
|
7
|
+
onCancel: () => void;
|
|
8
|
+
}
|
|
9
|
+
export declare function MultilinePrompt({ label, initialValue, rows, onSubmit, onCancel, }: MultilinePromptProps): React.JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useMemo, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { uiTheme } from '../theme.js';
|
|
5
|
+
import { backspaceAtOffset, deleteAtOffset, insertAtOffset, moveCursorHorizontal, moveCursorVertical, offsetToCursor, splitLines } from './multilinePromptModel.js';
|
|
6
|
+
function renderLineWithCursor(line, cursorColumn) {
|
|
7
|
+
const safeColumn = Math.max(0, Math.min(cursorColumn, line.length));
|
|
8
|
+
const before = line.slice(0, safeColumn);
|
|
9
|
+
const selectedChar = safeColumn < line.length ? line[safeColumn] : ' ';
|
|
10
|
+
const after = line.slice(safeColumn + (safeColumn < line.length ? 1 : 0));
|
|
11
|
+
return (_jsxs(Text, { color: uiTheme.ink.textPrimary, children: [before, _jsx(Text, { inverse: true, children: selectedChar }), after] }));
|
|
12
|
+
}
|
|
13
|
+
export function MultilinePrompt({ label, initialValue = '', rows = 8, onSubmit, onCancel, }) {
|
|
14
|
+
const [value, setValue] = useState(initialValue);
|
|
15
|
+
const [offset, setOffset] = useState(initialValue.length);
|
|
16
|
+
const [preferredColumn, setPreferredColumn] = useState(undefined);
|
|
17
|
+
const lines = useMemo(() => splitLines(value), [value]);
|
|
18
|
+
const cursor = useMemo(() => offsetToCursor(value, offset), [value, offset]);
|
|
19
|
+
const visibleRows = Math.max(3, rows);
|
|
20
|
+
const scrollTop = Math.max(0, Math.min(lines.length - visibleRows, cursor.line - Math.floor(visibleRows / 2)));
|
|
21
|
+
const visibleLines = lines.slice(scrollTop, scrollTop + visibleRows);
|
|
22
|
+
useInput((input, key) => {
|
|
23
|
+
if ((key.ctrl && input === 'c') || key.escape) {
|
|
24
|
+
onCancel();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (key.return) {
|
|
28
|
+
if (key.shift) {
|
|
29
|
+
const next = insertAtOffset(value, offset, '\n');
|
|
30
|
+
setValue(next.value);
|
|
31
|
+
setOffset(next.offset);
|
|
32
|
+
setPreferredColumn(undefined);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
onSubmit(value);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (key.leftArrow) {
|
|
39
|
+
setOffset((prev) => moveCursorHorizontal(value, prev, -1));
|
|
40
|
+
setPreferredColumn(undefined);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (key.rightArrow) {
|
|
44
|
+
setOffset((prev) => moveCursorHorizontal(value, prev, 1));
|
|
45
|
+
setPreferredColumn(undefined);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (key.upArrow) {
|
|
49
|
+
const moved = moveCursorVertical(value, offset, -1, preferredColumn);
|
|
50
|
+
setOffset(moved.nextOffset);
|
|
51
|
+
setPreferredColumn(moved.preferredColumn);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (key.downArrow) {
|
|
55
|
+
const moved = moveCursorVertical(value, offset, 1, preferredColumn);
|
|
56
|
+
setOffset(moved.nextOffset);
|
|
57
|
+
setPreferredColumn(moved.preferredColumn);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (key.backspace) {
|
|
61
|
+
const next = backspaceAtOffset(value, offset);
|
|
62
|
+
setValue(next.value);
|
|
63
|
+
setOffset(next.offset);
|
|
64
|
+
setPreferredColumn(undefined);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (key.delete) {
|
|
68
|
+
const next = deleteAtOffset(value, offset);
|
|
69
|
+
setValue(next.value);
|
|
70
|
+
setOffset(next.offset);
|
|
71
|
+
setPreferredColumn(undefined);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (input.length > 0 && !key.ctrl && !key.meta) {
|
|
75
|
+
const next = insertAtOffset(value, offset, input);
|
|
76
|
+
setValue(next.value);
|
|
77
|
+
setOffset(next.offset);
|
|
78
|
+
setPreferredColumn(undefined);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: uiTheme.ink.accent, children: label }), _jsx(Box, { borderStyle: "single", borderColor: uiTheme.ink.info, paddingLeft: 1, paddingRight: 1, flexDirection: "column", children: visibleLines.map((line, index) => {
|
|
82
|
+
const absoluteLine = scrollTop + index;
|
|
83
|
+
const isCursorLine = absoluteLine === cursor.line;
|
|
84
|
+
return (_jsx(React.Fragment, { children: isCursorLine ? renderLineWithCursor(line, cursor.column) : (_jsx(Text, { color: uiTheme.ink.textPrimary, children: line || ' ' })) }, `${absoluteLine}:${line}`));
|
|
85
|
+
}) }), _jsx(Text, { color: uiTheme.ink.textMuted, children: "Enter submit | Shift+Enter newline | Arrow keys move cursor | Esc cancel" })] }));
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=MultilinePrompt.js.map
|