@telepat/snoopy 0.1.14 → 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.
Files changed (33) hide show
  1. package/README.md +34 -9
  2. package/README.zh-CN.md +34 -9
  3. package/dist/src/cli/commands/feedback.d.ts +18 -0
  4. package/dist/src/cli/commands/feedback.js +276 -0
  5. package/dist/src/cli/commands/prompt.d.ts +6 -0
  6. package/dist/src/cli/commands/prompt.js +92 -0
  7. package/dist/src/cli/commands/promptEditor.d.ts +1 -0
  8. package/dist/src/cli/commands/promptEditor.js +17 -0
  9. package/dist/src/cli/flows/jobAddFlow.js +1 -1
  10. package/dist/src/cli/index.js +49 -1
  11. package/dist/src/mcp/helpers.d.ts +3 -0
  12. package/dist/src/mcp/helpers.js +78 -5
  13. package/dist/src/mcp/server.js +41 -2
  14. package/dist/src/mcp/tools.d.ts +13 -0
  15. package/dist/src/mcp/tools.js +16 -0
  16. package/dist/src/services/db/migrations/002_feedback_fields.d.ts +7 -0
  17. package/dist/src/services/db/migrations/002_feedback_fields.js +22 -0
  18. package/dist/src/services/db/migrations/index.js +2 -1
  19. package/dist/src/services/db/repositories/jobsRepo.d.ts +2 -0
  20. package/dist/src/services/db/repositories/jobsRepo.js +15 -0
  21. package/dist/src/services/db/repositories/scanItemsRepo.d.ts +17 -0
  22. package/dist/src/services/db/repositories/scanItemsRepo.js +197 -2
  23. package/dist/src/services/feedback/consolidationService.d.ts +28 -0
  24. package/dist/src/services/feedback/consolidationService.js +124 -0
  25. package/dist/src/services/openrouter/client.d.ts +23 -0
  26. package/dist/src/services/openrouter/client.js +67 -0
  27. package/dist/src/types/settings.d.ts +1 -1
  28. package/dist/src/types/settings.js +1 -1
  29. package/dist/src/ui/components/MultilinePrompt.d.ts +10 -0
  30. package/dist/src/ui/components/MultilinePrompt.js +87 -0
  31. package/dist/src/ui/components/multilinePromptModel.d.ts +25 -0
  32. package/dist/src/ui/components/multilinePromptModel.js +76 -0
  33. package/package.json +3 -1
@@ -1,23 +1,37 @@
1
- import { createRequire } from 'node:module';
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { spawnSync } from 'node:child_process';
3
4
  import { getDb } from '../services/db/sqlite.js';
4
5
  import { JobsRepository } from '../services/db/repositories/jobsRepo.js';
5
6
  import { RunsRepository } from '../services/db/repositories/runsRepo.js';
6
7
  import { ScanItemsRepository } from '../services/db/repositories/scanItemsRepo.js';
7
8
  import { SettingsRepository } from '../services/db/repositories/settingsRepo.js';
9
+ import { consolidateFeedback } from '../services/feedback/consolidationService.js';
8
10
  import { AnalyticsService } from '../services/analytics/analyticsService.js';
9
11
  import { extractErrorEntries, readRunLog } from '../services/logging/logReader.js';
10
12
  import { getOpenRouterApiKey, isKeytarAvailable } from '../services/security/secretStore.js';
11
13
  import { getStartupStatus } from '../services/startup/index.js';
12
14
  import { isDaemonRunning, ensureDaemonRunning, requestDaemonReload } from '../services/daemonControl.js';
13
15
  import { ensureAppDirs } from '../utils/paths.js';
14
- const require = createRequire(import.meta.url);
15
16
  export function getSnoopyVersion() {
16
- for (const rel of ['../../../package.json', '../../package.json']) {
17
+ const envVersion = process.env.npm_package_version;
18
+ if (envVersion) {
19
+ return envVersion;
20
+ }
21
+ const argvDir = process.argv[1] ? path.dirname(process.argv[1]) : process.cwd();
22
+ const candidates = [
23
+ path.resolve(process.cwd(), 'package.json'),
24
+ path.resolve(process.cwd(), 'snoopy/package.json'),
25
+ path.resolve(argvDir, '../package.json'),
26
+ path.resolve(argvDir, '../../package.json'),
27
+ ];
28
+ for (const candidate of candidates) {
17
29
  try {
18
- const pkg = require(rel);
19
- if (pkg.name === '@telepat/snoopy')
30
+ const raw = fs.readFileSync(candidate, 'utf8');
31
+ const pkg = JSON.parse(raw);
32
+ if (pkg.name === '@telepat/snoopy') {
20
33
  return pkg.version ?? '0.0.0';
34
+ }
21
35
  }
22
36
  catch { /* try next depth */ }
23
37
  }
@@ -308,6 +322,65 @@ export function consumeReport(jobRef, limit, dryRun) {
308
322
  const consumedCount = scanItemsRepo.markConsumed(rows.map((r) => r.id));
309
323
  return { consumed: consumedCount, items: rows };
310
324
  }
325
+ export function feedbackReviewReport(jobRef, limit) {
326
+ const jobsRepo = new JobsRepository();
327
+ const scanItemsRepo = new ScanItemsRepository();
328
+ let jobId;
329
+ if (jobRef) {
330
+ const job = jobsRepo.getByRef(jobRef);
331
+ if (!job) {
332
+ throw new Error(`Job not found: ${jobRef}`);
333
+ }
334
+ jobId = job.id;
335
+ }
336
+ const boundedLimit = limit ?? 10;
337
+ const items = scanItemsRepo.listUnvalidatedQualified(jobId, boundedLimit);
338
+ return {
339
+ count: items.length,
340
+ limit: boundedLimit,
341
+ items,
342
+ workflow: {
343
+ nextStep: 'collect-user-feedback-and-call-snoopy_feedback_submit',
344
+ then: 'call-snoopy_feedback_consolidate'
345
+ }
346
+ };
347
+ }
348
+ export function feedbackSubmitReport(resultId, isValid, reason) {
349
+ const scanItemsRepo = new ScanItemsRepository();
350
+ const row = scanItemsRepo.getQualifiedById(resultId);
351
+ if (!row) {
352
+ throw new Error(`Qualified result not found: ${resultId}`);
353
+ }
354
+ const normalizedReason = reason?.trim() ?? '';
355
+ if (!isValid && normalizedReason.length === 0) {
356
+ throw new Error('reason is required when isValid=false');
357
+ }
358
+ const updated = scanItemsRepo.submitFeedback(resultId, isValid, isValid ? null : normalizedReason);
359
+ if (!updated) {
360
+ throw new Error(`Failed to save feedback for result: ${resultId}`);
361
+ }
362
+ const pendingFeedbackConsolidationCount = scanItemsRepo.countPendingFeedbackConsolidation(row.jobId);
363
+ return {
364
+ resultId,
365
+ saved: true,
366
+ feedback: {
367
+ validated: true,
368
+ isValid,
369
+ isValidReason: isValid ? null : normalizedReason,
370
+ feedbackConsolidated: false,
371
+ },
372
+ pendingFeedbackConsolidationCount,
373
+ requiresConsolidation: pendingFeedbackConsolidationCount > 0,
374
+ recommendedNextCommand: 'snoopy feedback consolidate',
375
+ };
376
+ }
377
+ export async function feedbackConsolidateReport(jobRef, limit) {
378
+ const result = await consolidateFeedback({ jobRef, limit });
379
+ return {
380
+ ...result,
381
+ recommendedNextAction: result.requiresConsolidation ? 'run snoopy feedback consolidate again' : 'none',
382
+ };
383
+ }
311
384
  export function errorsReport(jobRef, hours) {
312
385
  const jobsRepo = new JobsRepository();
313
386
  const runsRepo = new RunsRepository();
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { snoopyDoctorToolInputSchema, snoopyDaemonStatusToolInputSchema, snoopyDaemonStartToolInputSchema, snoopyDaemonStopToolInputSchema, snoopyDaemonReloadToolInputSchema, snoopyJobListToolInputSchema, snoopyJobRunsToolInputSchema, snoopyJobAddToolInputSchema, snoopyJobDeleteToolInputSchema, snoopyJobEnableToolInputSchema, snoopyJobDisableToolInputSchema, snoopyJobRunToolInputSchema, snoopyAnalyticsToolInputSchema, snoopyExportToolInputSchema, snoopyConsumeToolInputSchema, snoopyErrorsToolInputSchema, snoopyLogsToolInputSchema, snoopySettingsGetToolInputSchema, snoopySettingsSetToolInputSchema, } from './tools.js';
4
- import { getSnoopyVersion, formatToolError, formatToolResult, buildDoctorReport, buildDaemonStatusReport, startDaemonReport, stopDaemonReport, reloadDaemonReport, listJobsReport, listJobRunsReport, addJobReport, deleteJobReport, enableJobReport, disableJobReport, runJobReport, analyticsReport, exportReport, consumeReport, errorsReport, logsReport, settingsGetReport, settingsSetReport, } from './helpers.js';
3
+ import { snoopyDoctorToolInputSchema, snoopyDaemonStatusToolInputSchema, snoopyDaemonStartToolInputSchema, snoopyDaemonStopToolInputSchema, snoopyDaemonReloadToolInputSchema, snoopyJobListToolInputSchema, snoopyJobRunsToolInputSchema, snoopyJobAddToolInputSchema, snoopyJobDeleteToolInputSchema, snoopyJobEnableToolInputSchema, snoopyJobDisableToolInputSchema, snoopyJobRunToolInputSchema, snoopyAnalyticsToolInputSchema, snoopyExportToolInputSchema, snoopyConsumeToolInputSchema, snoopyFeedbackReviewToolInputSchema, snoopyFeedbackSubmitToolInputSchema, snoopyFeedbackConsolidateToolInputSchema, snoopyErrorsToolInputSchema, snoopyLogsToolInputSchema, snoopySettingsGetToolInputSchema, snoopySettingsSetToolInputSchema, } from './tools.js';
4
+ import { getSnoopyVersion, formatToolError, formatToolResult, buildDoctorReport, buildDaemonStatusReport, startDaemonReport, stopDaemonReport, reloadDaemonReport, listJobsReport, listJobRunsReport, addJobReport, deleteJobReport, enableJobReport, disableJobReport, runJobReport, analyticsReport, exportReport, consumeReport, feedbackReviewReport, feedbackSubmitReport, feedbackConsolidateReport, errorsReport, logsReport, settingsGetReport, settingsSetReport, } from './helpers.js';
5
5
  export async function startSnoopyMcpServer() {
6
6
  const version = getSnoopyVersion();
7
7
  const server = new McpServer({ name: 'snoopy', version });
@@ -201,6 +201,45 @@ export async function startSnoopyMcpServer() {
201
201
  return formatToolError(error);
202
202
  }
203
203
  });
204
+ // --- snoopy_feedback_review ---
205
+ server.registerTool('snoopy_feedback_review', {
206
+ title: 'Review Feedback Queue',
207
+ description: 'List unvalidated qualified results for user feedback collection.',
208
+ inputSchema: snoopyFeedbackReviewToolInputSchema,
209
+ }, (input) => {
210
+ try {
211
+ return formatToolResult(feedbackReviewReport(input.jobRef, input.limit));
212
+ }
213
+ catch (error) {
214
+ return formatToolError(error);
215
+ }
216
+ });
217
+ // --- snoopy_feedback_submit ---
218
+ server.registerTool('snoopy_feedback_submit', {
219
+ title: 'Submit Feedback',
220
+ description: 'Submit validity feedback for a qualified result.',
221
+ inputSchema: snoopyFeedbackSubmitToolInputSchema,
222
+ }, (input) => {
223
+ try {
224
+ return formatToolResult(feedbackSubmitReport(input.resultId, input.isValid, input.reason));
225
+ }
226
+ catch (error) {
227
+ return formatToolError(error);
228
+ }
229
+ });
230
+ // --- snoopy_feedback_consolidate ---
231
+ server.registerTool('snoopy_feedback_consolidate', {
232
+ title: 'Consolidate Feedback',
233
+ description: 'Consolidate feedback into improved qualification prompts.',
234
+ inputSchema: snoopyFeedbackConsolidateToolInputSchema,
235
+ }, async (input) => {
236
+ try {
237
+ return formatToolResult(await feedbackConsolidateReport(input.jobRef, input.limit));
238
+ }
239
+ catch (error) {
240
+ return formatToolError(error);
241
+ }
242
+ });
204
243
  // --- snoopy_errors ---
205
244
  server.registerTool('snoopy_errors', {
206
245
  title: 'Recent Errors',
@@ -49,6 +49,19 @@ export declare const snoopyConsumeToolInputSchema: {
49
49
  limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
50
50
  dryRun: z.ZodOptional<z.ZodBoolean>;
51
51
  };
52
+ export declare const snoopyFeedbackReviewToolInputSchema: {
53
+ jobRef: z.ZodOptional<z.ZodString>;
54
+ limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
55
+ };
56
+ export declare const snoopyFeedbackSubmitToolInputSchema: {
57
+ resultId: z.ZodString;
58
+ isValid: z.ZodBoolean;
59
+ reason: z.ZodOptional<z.ZodString>;
60
+ };
61
+ export declare const snoopyFeedbackConsolidateToolInputSchema: {
62
+ jobRef: z.ZodOptional<z.ZodString>;
63
+ limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
64
+ };
52
65
  export declare const snoopyErrorsToolInputSchema: {
53
66
  jobRef: z.ZodString;
54
67
  hours: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
@@ -46,6 +46,19 @@ export const snoopyConsumeToolInputSchema = {
46
46
  limit: z.coerce.number().int().positive().optional().describe('Max results to consume.'),
47
47
  dryRun: z.boolean().optional().describe('Preview without marking consumed.'),
48
48
  };
49
+ export const snoopyFeedbackReviewToolInputSchema = {
50
+ jobRef: z.string().optional().describe('Job ID or slug. If omitted, review queue spans all jobs.'),
51
+ limit: z.coerce.number().int().positive().optional().describe('Max unvalidated results to return. Default: 10.'),
52
+ };
53
+ export const snoopyFeedbackSubmitToolInputSchema = {
54
+ resultId: z.string().min(1).describe('Qualified result ID to update.'),
55
+ isValid: z.boolean().describe('Whether the result should be considered valid.'),
56
+ reason: z.string().optional().describe('Required when isValid=false; ignored when isValid=true.'),
57
+ };
58
+ export const snoopyFeedbackConsolidateToolInputSchema = {
59
+ jobRef: z.string().optional().describe('Job ID or slug. If omitted, consolidates across all jobs.'),
60
+ limit: z.coerce.number().int().positive().optional().describe('Max pending feedback items to process.'),
61
+ };
49
62
  export const snoopyErrorsToolInputSchema = {
50
63
  jobRef: z.string().min(1).describe('Job ID or slug.'),
51
64
  hours: z.coerce.number().int().positive().optional().describe('Look back hours. Default: 24.'),
@@ -82,6 +95,9 @@ export const snoopyToolContracts = [
82
95
  { name: 'snoopy_analytics', required: [], enums: {} },
83
96
  { name: 'snoopy_export', required: [], enums: { format: ['json', 'csv'] } },
84
97
  { name: 'snoopy_consume', required: [], enums: {} },
98
+ { name: 'snoopy_feedback_review', required: [], enums: {} },
99
+ { name: 'snoopy_feedback_submit', required: ['resultId', 'isValid'], enums: {} },
100
+ { name: 'snoopy_feedback_consolidate', required: [], enums: {} },
85
101
  { name: 'snoopy_errors', required: ['jobRef'], enums: {} },
86
102
  { name: 'snoopy_logs', required: ['runId'], enums: {} },
87
103
  { name: 'snoopy_settings_get', required: [], enums: {} },
@@ -0,0 +1,7 @@
1
+ import type Database from 'better-sqlite3';
2
+ declare const _default: {
3
+ id: number;
4
+ name: string;
5
+ up(db: Database.Database): void;
6
+ };
7
+ export default _default;
@@ -0,0 +1,22 @@
1
+ export default {
2
+ id: 2,
3
+ name: 'feedback_fields',
4
+ up(db) {
5
+ const safeAddColumn = (table, column, type) => {
6
+ try {
7
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
8
+ }
9
+ catch {
10
+ // Column already exists or table does not exist.
11
+ }
12
+ };
13
+ safeAddColumn('scan_items', 'is_valid', 'INTEGER NOT NULL DEFAULT 0');
14
+ safeAddColumn('scan_items', 'is_valid_reason', 'TEXT');
15
+ safeAddColumn('scan_items', 'feedback_consolidated', 'INTEGER NOT NULL DEFAULT 0');
16
+ db.exec(`
17
+ CREATE INDEX IF NOT EXISTS idx_scan_items_feedback_pending
18
+ ON scan_items(job_id, qualified, validated, feedback_consolidated, created_at DESC)
19
+ `);
20
+ }
21
+ };
22
+ //# sourceMappingURL=002_feedback_fields.js.map
@@ -1,3 +1,4 @@
1
1
  import baseline from './001_baseline.js';
2
- export const migrations = [baseline];
2
+ import feedbackFields from './002_feedback_fields.js';
3
+ export const migrations = [baseline, feedbackFields];
3
4
  //# sourceMappingURL=index.js.map
@@ -28,6 +28,8 @@ export declare class JobsRepository {
28
28
  listEnabled(): Job[];
29
29
  setEnabled(id: string, enabled: boolean): void;
30
30
  setEnabledByRef(ref: string, enabled: boolean): Job | null;
31
+ updateQualificationPromptById(id: string, qualificationPrompt: string): Job | null;
32
+ updateQualificationPromptByRef(ref: string, qualificationPrompt: string): Job | null;
31
33
  remove(id: string): void;
32
34
  removeByRef(ref: string): Job | null;
33
35
  listWithStats(): JobSummaryRow[];
@@ -141,6 +141,21 @@ export class JobsRepository {
141
141
  this.setEnabled(job.id, enabled);
142
142
  return this.getById(job.id);
143
143
  }
144
+ updateQualificationPromptById(id, qualificationPrompt) {
145
+ this.db
146
+ .prepare(`UPDATE jobs
147
+ SET qualification_prompt = ?, updated_at = datetime('now')
148
+ WHERE id = ?`)
149
+ .run(qualificationPrompt, id);
150
+ return this.getById(id);
151
+ }
152
+ updateQualificationPromptByRef(ref, qualificationPrompt) {
153
+ const job = this.getByRef(ref);
154
+ if (!job) {
155
+ return null;
156
+ }
157
+ return this.updateQualificationPromptById(job.id, qualificationPrompt);
158
+ }
144
159
  remove(id) {
145
160
  this.removeCascadeStmt(id);
146
161
  }
@@ -14,6 +14,9 @@ export interface NewScanItem {
14
14
  qualified: boolean;
15
15
  viewed?: boolean;
16
16
  validated?: boolean;
17
+ isValid?: boolean;
18
+ isValidReason?: string | null;
19
+ feedbackConsolidated?: boolean;
17
20
  processed?: boolean;
18
21
  consumed?: boolean;
19
22
  promptTokens?: number;
@@ -39,6 +42,8 @@ export interface QualifiedScanItemRow {
39
42
  id: string;
40
43
  jobId: string;
41
44
  runId: string;
45
+ type: ScanItemType;
46
+ subreddit: string;
42
47
  author: string;
43
48
  title: string | null;
44
49
  body: string;
@@ -46,6 +51,9 @@ export interface QualifiedScanItemRow {
46
51
  redditPostedAt: string;
47
52
  viewed: boolean;
48
53
  validated: boolean;
54
+ isValid: boolean;
55
+ isValidReason: string | null;
56
+ feedbackConsolidated: boolean;
49
57
  processed: boolean;
50
58
  consumed: boolean;
51
59
  qualificationReason: string | null;
@@ -67,6 +75,9 @@ export interface ScanItemRow {
67
75
  qualified: boolean;
68
76
  viewed: boolean;
69
77
  validated: boolean;
78
+ isValid: boolean;
79
+ isValidReason: string | null;
80
+ feedbackConsolidated: boolean;
70
81
  processed: boolean;
71
82
  consumed: boolean;
72
83
  qualificationReason: string | null;
@@ -117,6 +128,12 @@ export declare class ScanItemsRepository {
117
128
  listAnalyticsByJob(days: number): AnalyticsByJobRow[];
118
129
  existsComment(jobId: string, postId: string, commentId: string): boolean;
119
130
  listUnconsumedQualified(jobId?: string, limit?: number): QualifiedScanItemRow[];
131
+ getQualifiedById(id: string): QualifiedScanItemRow | null;
132
+ listUnvalidatedQualified(jobId?: string, limit?: number): QualifiedScanItemRow[];
133
+ submitFeedback(resultId: string, isValid: boolean, reason: string | null): boolean;
134
+ listPendingFeedbackConsolidation(jobId?: string, limit?: number): QualifiedScanItemRow[];
135
+ countPendingFeedbackConsolidation(jobId?: string): number;
136
+ markFeedbackConsolidated(ids: string[]): number;
120
137
  markConsumed(ids: string[]): number;
121
138
  createWithStatus(item: NewScanItem): CreateScanItemResult;
122
139
  create(item: NewScanItem): string;
@@ -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>;