pixivflow 2.28.1 → 2.30.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.
Files changed (32) hide show
  1. package/dist/commands/scheduler-runtime.js +1 -0
  2. package/dist/config/types.d.ts +31 -0
  3. package/dist/config/validation.js +35 -0
  4. package/dist/delivery/DeliveryService.d.ts +1 -0
  5. package/dist/delivery/HttpMultipartDelivery.js +1 -0
  6. package/dist/delivery/types.d.ts +2 -0
  7. package/dist/download/handlers/IllustrationTargetHandler.d.ts +13 -0
  8. package/dist/download/handlers/IllustrationTargetHandler.js +99 -3
  9. package/dist/download/handlers/NovelTargetHandler.d.ts +11 -0
  10. package/dist/download/handlers/NovelTargetHandler.js +92 -3
  11. package/dist/download/inventory.d.ts +29 -0
  12. package/dist/download/inventory.js +80 -0
  13. package/dist/interfaces/IDatabase.d.ts +3 -0
  14. package/dist/notification/NotificationPolicy.d.ts +1 -0
  15. package/dist/notification/NotificationPolicy.js +1 -0
  16. package/dist/package.json +1 -1
  17. package/dist/scheduler/SlotCoordinator.d.ts +6 -0
  18. package/dist/scheduler/SlotCoordinator.js +26 -0
  19. package/dist/scheduler/TargetOutcome.d.ts +59 -0
  20. package/dist/scheduler/TargetOutcome.js +62 -0
  21. package/dist/storage/Database.d.ts +4 -0
  22. package/dist/storage/Database.js +7 -0
  23. package/dist/storage/DatabaseMigration.js +24 -0
  24. package/dist/storage/repositories/CandidateInventoryRepository.d.ts +51 -0
  25. package/dist/storage/repositories/CandidateInventoryRepository.js +129 -0
  26. package/dist/storage/repositories/SlotRepository.d.ts +11 -0
  27. package/dist/storage/repositories/SlotRepository.js +17 -0
  28. package/dist/topic/TopicPipeline.d.ts +2 -0
  29. package/dist/topic/TopicPipeline.js +5 -1
  30. package/dist/version.js +1 -1
  31. package/dist/webui/package.json +1 -1
  32. package/package.json +1 -1
@@ -67,6 +67,8 @@ export interface SlotCellSummary {
67
67
  terminalReasonCode?: string | null;
68
68
  /** User-facing business reason message (§terminal-reason). */
69
69
  terminalReasonMessage?: string | null;
70
+ /** Phase 1 Candidate Supply Report attached to the durable cell. */
71
+ candidateReport?: Record<string, unknown> | null;
70
72
  }
71
73
  export interface SlotRunSummary {
72
74
  scheduleId: string;
@@ -82,6 +84,8 @@ export interface ScheduleOutcomeTarget {
82
84
  status: CellStatus;
83
85
  work_id: string | null;
84
86
  error: string | null;
87
+ /** Phase 1 Candidate Supply Report ({fetched, selected, rejected, reasons}). */
88
+ candidate_report: Record<string, unknown> | null;
85
89
  }
86
90
  /**
87
91
  * Business categories an operator actually asks about, counted per cell. The
@@ -254,6 +258,8 @@ export declare class SlotCoordinator {
254
258
  * sole path to the submitted cell state.
255
259
  */
256
260
  applyOutcome(slotId: string, targetId: string, outcome: TargetOutcome): void;
261
+ /** Persist the Phase 1 candidate-report funnel for a target cell. */
262
+ private persistCandidateReport;
257
263
  /**
258
264
  * Persist the normalized terminal reason (§terminal-reason) for a cell that
259
265
  * just reached a terminal state. Never unwinds the run and never leaks raw
@@ -255,6 +255,7 @@ class SlotCoordinator {
255
255
  switch (outcome.kind) {
256
256
  case 'submitted':
257
257
  this.database.slots.lockCellWork(slotId, targetId, outcome.workId, outcome.workType);
258
+ this.persistCandidateReport(slotId, targetId, outcome);
258
259
  this.safeTransition(slotId, targetId, 'submitted');
259
260
  return;
260
261
  case 'stored':
@@ -262,22 +263,26 @@ class SlotCoordinator {
262
263
  // cell, but labelled via the ledger-free 'submitted' aggregate state so
263
264
  // download-only schedules do not rerun forever.
264
265
  this.database.slots.lockCellWork(slotId, targetId, outcome.workId, outcome.workType);
266
+ this.persistCandidateReport(slotId, targetId, outcome);
265
267
  this.safeTransition(slotId, targetId, 'submitted');
266
268
  return;
267
269
  case 'delivery_pending':
268
270
  this.database.slots.lockCellWork(slotId, targetId, outcome.workId, outcome.workType);
271
+ this.persistCandidateReport(slotId, targetId, outcome);
269
272
  this.safeTransition(slotId, targetId, 'delivery_pending');
270
273
  return;
271
274
  case 'no_candidate':
272
275
  // Only terminal if the cell never locked a work; a locked work whose
273
276
  // delivery is still pending must not be collapsed to no_candidate.
274
277
  if (!cell.workId) {
278
+ this.persistCandidateReport(slotId, targetId, outcome);
275
279
  this.safeTransition(slotId, targetId, 'no_candidate', outcome.reason);
276
280
  this.persistTerminalReason(slotId, targetId, outcome);
277
281
  }
278
282
  return;
279
283
  case 'duplicate':
280
284
  this.database.slots.lockCellWork(slotId, targetId, outcome.workId, cell.workType ?? 'unknown');
285
+ this.persistCandidateReport(slotId, targetId, outcome);
281
286
  this.safeTransition(slotId, targetId, 'duplicate', outcome.reason);
282
287
  this.persistTerminalReason(slotId, targetId, outcome);
283
288
  return;
@@ -286,13 +291,31 @@ class SlotCoordinator {
286
291
  // Leave non-terminal (selected/delivery_pending) so a later trigger
287
292
  // resumes the SAME work. Record the error without a terminal state.
288
293
  this.database.slots.setCellError?.(slotId, targetId, outcome.error);
294
+ this.persistCandidateReport(slotId, targetId, outcome);
289
295
  return;
290
296
  }
297
+ this.persistCandidateReport(slotId, targetId, outcome);
291
298
  this.safeTransition(slotId, targetId, 'failed', outcome.error);
292
299
  this.persistTerminalReason(slotId, targetId, outcome);
293
300
  return;
294
301
  }
295
302
  }
303
+ /** Persist the Phase 1 candidate-report funnel for a target cell. */
304
+ persistCandidateReport(slotId, targetId, outcome) {
305
+ const report = outcome.scan?.supply;
306
+ if (!report)
307
+ return;
308
+ try {
309
+ this.database.slots.setCellCandidateReport(slotId, targetId, report);
310
+ }
311
+ catch (error) {
312
+ logger_1.logger.debug('Failed to persist candidate report', {
313
+ slot: slotId,
314
+ target: targetId,
315
+ error: error instanceof Error ? error.message : String(error),
316
+ });
317
+ }
318
+ }
296
319
  /**
297
320
  * Persist the normalized terminal reason (§terminal-reason) for a cell that
298
321
  * just reached a terminal state. Never unwinds the run and never leaks raw
@@ -444,6 +467,7 @@ class SlotCoordinator {
444
467
  error: c.lastError,
445
468
  terminalReasonCode: c.terminalReasonCode,
446
469
  terminalReasonMessage: c.terminalReasonMessage,
470
+ candidateReport: c.candidateReport,
447
471
  }));
448
472
  // Rolled up AFTER the slot row carries its terminal status/completed_at, so
449
473
  // the outcome reports the durable timestamps rather than a fresh clock read.
@@ -493,6 +517,7 @@ class SlotCoordinator {
493
517
  error: cell.lastError,
494
518
  terminal_reason_code: cell.terminalReasonCode,
495
519
  terminal_reason_message: cell.terminalReasonMessage,
520
+ candidate_report: cell.candidateReport,
496
521
  };
497
522
  });
498
523
  // A fully-submitted slot has no non-submitted cell at all, and reporting
@@ -611,6 +636,7 @@ class SlotCoordinator {
611
636
  status: c.status,
612
637
  workId: c.workId,
613
638
  error: c.lastError,
639
+ candidateReport: c.candidateReport,
614
640
  }));
615
641
  const slotRec = this.database.slots.getSlot(slotId);
616
642
  return {
@@ -96,6 +96,59 @@ export type CandidateAttempt =
96
96
  kind: 'skipped';
97
97
  skip: CandidateSkip;
98
98
  };
99
+ /**
100
+ * Upstream candidate-supply funnel for one target run (topic pipeline).
101
+ *
102
+ * Deliberately an open `reasons` bucket, NOT a fixed list of columns:
103
+ * illustration and novel filter chains differ, and future TopicProfile /
104
+ * CandidateInventory phases must be able to extend the taxonomy without a
105
+ * breaking schema change.
106
+ */
107
+ export interface CandidateSupplyReason {
108
+ /** Stable machine-readable reason code, e.g. `duplicate`, `ai_filtered`. */
109
+ code: string;
110
+ count: number;
111
+ }
112
+ /** Phase 5: durable 待发池 snapshot for sparse topics (only when enabled). */
113
+ export interface CandidateInventoryReport {
114
+ /** Rows still usable for fallback at report time. */
115
+ pendingCount: number;
116
+ /** Target's configured max retained rows. */
117
+ reserveSize: number;
118
+ /** Max age in days before a pending candidate expires. */
119
+ maxAgeDays: number;
120
+ /** Earliest still-pending first-seen date (YYYY-MM-DD), null when empty. */
121
+ oldestSeenDate: string | null;
122
+ }
123
+ /** Candidate-supply observability snapshot (Phase 1 Candidate Report). */
124
+ export interface CandidateSupplyReport {
125
+ /** Total works surfaced by the topic search before any filtering. */
126
+ fetched: number;
127
+ /** Candidates that survived upload selection (the pool handed to the scan). */
128
+ selected: number;
129
+ /** Count rejected by all filters (fetched - selected is a stable invariant). */
130
+ rejected: number;
131
+ /** Why candidates were rejected, by reason code (extensible). */
132
+ reasons: CandidateSupplyReason[];
133
+ /** Phase 5: durable 待发池 reserve (optional, present only when enabled). */
134
+ inventory?: CandidateInventoryReport;
135
+ }
136
+ /**
137
+ * A freshly-harvested upstream funnel with no candidates selected yet.
138
+ */
139
+ export declare function emptyCandidateSupplyReport(): CandidateSupplyReport;
140
+ /**
141
+ * Fold two supply snapshots of the SAME logical target together, so a
142
+ * multi-day lookback scan reports the whole funnel rather than only its last
143
+ * day. Reasons are summed by code.
144
+ */
145
+ export declare function mergeCandidateSupplyReports(first: CandidateSupplyReport | undefined, second: CandidateSupplyReport): CandidateSupplyReport;
146
+ /**
147
+ * Fold the scan-level (download-time) candidate skips into the upstream
148
+ * candidate report. `selected` is reduced by every usable candidate actually
149
+ * offered to the scan, and the skip reasons join the upstream reasons.
150
+ */
151
+ export declare function withScanSkips(report: CandidateSupplyReport | undefined, scan: CandidateScanSummary | undefined): CandidateSupplyReport | undefined;
99
152
  /**
100
153
  * Bookkeeping for a target's bounded candidate scan. Attached to the terminal
101
154
  * target outcome so the run can state exactly one of:
@@ -122,6 +175,12 @@ export interface CandidateScanSummary {
122
175
  skipped: CandidateSkip[];
123
176
  /** Job-level outages observed while scanning (never a candidate verdict). */
124
177
  outages: JobLevelOutage[];
178
+ /**
179
+ * Upstream candidate-supply funnel that produced this scan, when the target
180
+ * ran through the topic pipeline (illustration or novel). Optional so ranking
181
+ * / search targets without a topic funnel keep working unchanged.
182
+ */
183
+ supply?: CandidateSupplyReport;
125
184
  }
126
185
  /**
127
186
  * A scan that attempted nothing. The real pipeline always reports its own scan;
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TERMINAL_REASON_MESSAGES = void 0;
4
+ exports.emptyCandidateSupplyReport = emptyCandidateSupplyReport;
5
+ exports.mergeCandidateSupplyReports = mergeCandidateSupplyReports;
6
+ exports.withScanSkips = withScanSkips;
4
7
  exports.emptyCandidateScan = emptyCandidateScan;
5
8
  exports.isTerminalOutcome = isTerminalOutcome;
6
9
  exports.isSelectedAttempt = isSelectedAttempt;
@@ -13,6 +16,62 @@ exports.classifyCandidateFailure = classifyCandidateFailure;
13
16
  exports.classifyJobLevelOutage = classifyJobLevelOutage;
14
17
  exports.operationalReasonForCode = operationalReasonForCode;
15
18
  exports.terminalReasonFor = terminalReasonFor;
19
+ /**
20
+ * A freshly-harvested upstream funnel with no candidates selected yet.
21
+ */
22
+ function emptyCandidateSupplyReport() {
23
+ return { fetched: 0, selected: 0, rejected: 0, reasons: [], inventory: undefined };
24
+ }
25
+ /**
26
+ * Fold two supply snapshots of the SAME logical target together, so a
27
+ * multi-day lookback scan reports the whole funnel rather than only its last
28
+ * day. Reasons are summed by code.
29
+ */
30
+ function mergeCandidateSupplyReports(first, second) {
31
+ if (!first)
32
+ return second;
33
+ const byCode = new Map();
34
+ for (const r of [...first.reasons, ...second.reasons]) {
35
+ byCode.set(r.code, (byCode.get(r.code) ?? 0) + r.count);
36
+ }
37
+ const reasons = [...byCode.entries()]
38
+ .map(([code, count]) => ({ code, count }))
39
+ .filter((r) => r.count > 0)
40
+ .sort((a, b) => b.count - a.count);
41
+ return {
42
+ fetched: first.fetched + second.fetched,
43
+ selected: first.selected + second.selected,
44
+ rejected: Math.max(0, first.fetched + second.fetched - (first.selected + second.selected)),
45
+ reasons,
46
+ inventory: second.inventory ?? first.inventory,
47
+ };
48
+ }
49
+ /**
50
+ * Fold the scan-level (download-time) candidate skips into the upstream
51
+ * candidate report. `selected` is reduced by every usable candidate actually
52
+ * offered to the scan, and the skip reasons join the upstream reasons.
53
+ */
54
+ function withScanSkips(report, scan) {
55
+ if (!scan || scan.skipped.length === 0)
56
+ return report;
57
+ const base = report ?? emptyCandidateSupplyReport();
58
+ const byCode = new Map(base.reasons.map((r) => [r.code, r.count]));
59
+ for (const skip of scan.skipped) {
60
+ byCode.set(skip.code, (byCode.get(skip.code) ?? 0) + 1);
61
+ }
62
+ const reasons = [...byCode.entries()]
63
+ .map(([code, count]) => ({ code, count }))
64
+ .filter((r) => r.count > 0)
65
+ .sort((a, b) => b.count - a.count);
66
+ const selected = Math.max(0, base.selected - scan.skipped.length);
67
+ return {
68
+ fetched: base.fetched,
69
+ selected,
70
+ rejected: Math.max(0, base.fetched - selected),
71
+ reasons,
72
+ inventory: base.inventory,
73
+ };
74
+ }
16
75
  /**
17
76
  * A scan that attempted nothing. The real pipeline always reports its own scan;
18
77
  * this is for callers/tests that have no candidate-level information, so they
@@ -130,6 +189,9 @@ function mergeScanSummaries(first, second) {
130
189
  attempted: first.attempted + second.attempted,
131
190
  skipped: [...first.skipped, ...second.skipped],
132
191
  outages: [...new Set([...first.outages, ...second.outages])],
192
+ supply: second.supply
193
+ ? mergeCandidateSupplyReports(first.supply, second.supply)
194
+ : first.supply,
133
195
  };
134
196
  }
135
197
  const SQLITE_OUTAGE = /sqlite|database (?:is )?locked|unable to open database|no such table|disk i\/o error/i;
@@ -5,6 +5,7 @@ import { OutboxRepository } from './repositories/OutboxRepository';
5
5
  import { MetadataRepository } from './repositories/MetadataRepository';
6
6
  import { SQLiteRateLimitStateStore } from './repositories/RateLimitStateRepository';
7
7
  import { SystemErrorRepository } from './repositories/SystemErrorRepository';
8
+ import { CandidateInventoryRepository } from './repositories/CandidateInventoryRepository';
8
9
  export interface AccessTokenStore {
9
10
  accessToken: string;
10
11
  expiresAt: number;
@@ -48,6 +49,7 @@ export declare class Database implements IDatabase {
48
49
  private metadataRepo;
49
50
  private rateLimitStateStore;
50
51
  private systemErrorRepo;
52
+ private candidateInventoryRepo;
51
53
  constructor(databasePath: string);
52
54
  migrate(): void;
53
55
  /** Absolute path of the SQLite file (used to locate sibling cache dirs). */
@@ -64,6 +66,8 @@ export declare class Database implements IDatabase {
64
66
  get rateLimitState(): SQLiteRateLimitStateStore;
65
67
  /** Durable system-error ledger (observability; never throws). */
66
68
  get systemErrors(): SystemErrorRepository;
69
+ /** Phase 5 CandidateInventory (待发池) for sparse topics. */
70
+ get candidateInventory(): CandidateInventoryRepository;
67
71
  /** Raw transactional boundary for atomic multi-table intents. */
68
72
  transaction<T>(fn: () => T): T;
69
73
  /** Expose a prepared-statement helper if needed by services (pragmas etc). */
@@ -18,6 +18,7 @@ const OutboxRepository_1 = require("./repositories/OutboxRepository");
18
18
  const MetadataRepository_1 = require("./repositories/MetadataRepository");
19
19
  const RateLimitStateRepository_1 = require("./repositories/RateLimitStateRepository");
20
20
  const SystemErrorRepository_1 = require("./repositories/SystemErrorRepository");
21
+ const CandidateInventoryRepository_1 = require("./repositories/CandidateInventoryRepository");
21
22
  const NodeSqliteDriver_1 = require("./drivers/NodeSqliteDriver");
22
23
  class Database {
23
24
  databasePath;
@@ -35,6 +36,7 @@ class Database {
35
36
  metadataRepo;
36
37
  rateLimitStateStore;
37
38
  systemErrorRepo;
39
+ candidateInventoryRepo;
38
40
  constructor(databasePath) {
39
41
  this.databasePath = databasePath;
40
42
  try {
@@ -63,6 +65,7 @@ class Database {
63
65
  this.metadataRepo = new MetadataRepository_1.MetadataRepository(this.db);
64
66
  this.rateLimitStateStore = new RateLimitStateRepository_1.SQLiteRateLimitStateStore(this.db);
65
67
  this.systemErrorRepo = new SystemErrorRepository_1.SystemErrorRepository(this.db);
68
+ this.candidateInventoryRepo = new CandidateInventoryRepository_1.CandidateInventoryRepository(this.db);
66
69
  }
67
70
  catch (error) {
68
71
  throw new errors_1.DatabaseError(`Failed to initialize database at ${this.databasePath}`, error instanceof Error ? error : undefined);
@@ -102,6 +105,10 @@ class Database {
102
105
  get systemErrors() {
103
106
  return this.systemErrorRepo;
104
107
  }
108
+ /** Phase 5 CandidateInventory (待发池) for sparse topics. */
109
+ get candidateInventory() {
110
+ return this.candidateInventoryRepo;
111
+ }
105
112
  /** Raw transactional boundary for atomic multi-table intents. */
106
113
  transaction(fn) {
107
114
  return this.db.transaction(fn)();
@@ -135,6 +135,7 @@ class DatabaseMigration {
135
135
  -- code plus a user-facing business message, durable across restarts.
136
136
  terminal_reason_code TEXT,
137
137
  terminal_reason_message TEXT,
138
+ candidate_report TEXT,
138
139
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
139
140
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
140
141
  completed_at DATETIME,
@@ -221,6 +222,25 @@ class DatabaseMigration {
221
222
  scope TEXT PRIMARY KEY,
222
223
  state TEXT NOT NULL,
223
224
  updated_at INTEGER NOT NULL
225
+ )`,
226
+ // Phase 5 CandidateInventory: durable 待发池 for sparse topics. Same
227
+ // database and transaction world as the Slot Ledger; never a second
228
+ // state authority. Only populated when target.topicProfile.inventory.enabled.
229
+ `CREATE TABLE IF NOT EXISTS candidate_inventory (
230
+ pixiv_id TEXT NOT NULL,
231
+ work_type TEXT NOT NULL,
232
+ topic TEXT NOT NULL,
233
+ target_id TEXT NOT NULL,
234
+ status TEXT NOT NULL DEFAULT 'pending',
235
+ snapshot_json TEXT NOT NULL,
236
+ first_seen_date TEXT NOT NULL,
237
+ last_seen_date TEXT NOT NULL,
238
+ seen_count INTEGER NOT NULL DEFAULT 1,
239
+ attempt_count INTEGER NOT NULL DEFAULT 0,
240
+ expires_at TEXT NOT NULL,
241
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
242
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
243
+ PRIMARY KEY (pixiv_id, work_type, topic, target_id)
224
244
  )`,
225
245
  // Durable error event ledger for observability (structured error
226
246
  // taxonomy; populated by download/system handlers and shown in the
@@ -243,6 +263,7 @@ class DatabaseMigration {
243
263
  resolved_at DATETIME
244
264
  )`,
245
265
  `CREATE INDEX IF NOT EXISTS idx_system_errors_bot_created ON system_errors(bot_id, created_at)`,
266
+ `CREATE INDEX IF NOT EXISTS idx_cinventory_pending ON candidate_inventory(status, first_seen_date, expires_at)`,
246
267
  ];
247
268
  // Phase 1: create tables (idempotent). Must run before any PRAGMA-based
248
269
  // column check, otherwise a fresh DB would report the table as missing and
@@ -287,6 +308,9 @@ class DatabaseMigration {
287
308
  if (!itemCols.includes('terminal_reason_message')) {
288
309
  columnAlters.push(`ALTER TABLE schedule_slot_items ADD COLUMN terminal_reason_message TEXT`);
289
310
  }
311
+ if (!itemCols.includes('candidate_report')) {
312
+ columnAlters.push(`ALTER TABLE schedule_slot_items ADD COLUMN candidate_report TEXT`);
313
+ }
290
314
  // Create indexes for better query performance
291
315
  const indexes = [
292
316
  `CREATE INDEX IF NOT EXISTS idx_downloads_pixiv_id_type ON downloads(pixiv_id, type)`,
@@ -0,0 +1,51 @@
1
+ import { BaseRepository } from './BaseRepository';
2
+ export type CandidateInventoryStatus = 'pending' | 'selected' | 'submitted' | 'filtered' | 'expired';
3
+ export interface CandidateInventoryRow {
4
+ pixivId: string;
5
+ workType: 'illustration' | 'novel';
6
+ topic: string;
7
+ targetId: string;
8
+ status: CandidateInventoryStatus;
9
+ snapshotJson: string;
10
+ firstSeenDate: string;
11
+ lastSeenDate: string;
12
+ seenCount: number;
13
+ attemptCount: number;
14
+ expiresAt: string;
15
+ createdAt: string;
16
+ updatedAt: string;
17
+ }
18
+ export interface CandidateInventorySnapshot {
19
+ pixivId: string;
20
+ workType: 'illustration' | 'novel';
21
+ topic: string;
22
+ targetId: string;
23
+ snapshot: unknown;
24
+ date: string;
25
+ maxAgeDays: number;
26
+ }
27
+ /** Phase 5 durable 待发池 — same SQLite database as the Slot Ledger. */
28
+ export declare class CandidateInventoryRepository extends BaseRepository {
29
+ upsert(input: CandidateInventorySnapshot): void;
30
+ /** Marks a claimed candidate and returns its snapshot for the pipeline. */
31
+ claimNext(input: {
32
+ topic: string;
33
+ targetId: string;
34
+ reserveSize: number;
35
+ date: string;
36
+ }): CandidateInventoryRow | null;
37
+ markSubmitted(pixivId: string, workType: string, topic: string, targetId: string): void;
38
+ markFiltered(pixivId: string, workType: string, topic: string, targetId: string): void;
39
+ markSelectedBackToPending(pixivId: string, workType: string, topic: string, targetId: string): void;
40
+ countPending(topic: string, targetId: string): number;
41
+ pendingSummary(topic: string, targetId: string): {
42
+ count: number;
43
+ oldestSeenDate: string | null;
44
+ };
45
+ /** Sweep rows past maxAgeDays; idempotent per scheduled scan. */
46
+ evictExpired(topic: string, targetId: string): number;
47
+ private toRow;
48
+ private todayUtc;
49
+ private addDaysUtc;
50
+ }
51
+ //# sourceMappingURL=CandidateInventoryRepository.d.ts.map
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CandidateInventoryRepository = void 0;
4
+ const BaseRepository_1 = require("./BaseRepository");
5
+ /** Phase 5 durable 待发池 — same SQLite database as the Slot Ledger. */
6
+ class CandidateInventoryRepository extends BaseRepository_1.BaseRepository {
7
+ upsert(input) {
8
+ const expiresAt = this.addDaysUtc(input.date, input.maxAgeDays);
9
+ this.db
10
+ .prepare(`INSERT INTO candidate_inventory
11
+ (pixiv_id, work_type, topic, target_id, status, snapshot_json,
12
+ first_seen_date, last_seen_date, seen_count, attempt_count, expires_at)
13
+ VALUES
14
+ (@pixivId, @workType, @topic, @targetId, 'pending', @snapshot,
15
+ @date, @date, 1, 0, @expiresAt)
16
+ ON CONFLICT(pixiv_id, work_type, topic, target_id) DO UPDATE SET
17
+ snapshot_json = excluded.snapshot_json,
18
+ last_seen_date = excluded.last_seen_date,
19
+ seen_count = candidate_inventory.seen_count + 1,
20
+ status = CASE
21
+ WHEN candidate_inventory.status IN ('selected','submitted','filtered','expired')
22
+ THEN candidate_inventory.status
23
+ ELSE 'pending'
24
+ END,
25
+ expires_at = excluded.expires_at,
26
+ updated_at = CURRENT_TIMESTAMP`)
27
+ .run({
28
+ pixivId: input.pixivId,
29
+ workType: input.workType,
30
+ topic: input.topic,
31
+ targetId: input.targetId,
32
+ snapshot: JSON.stringify(input.snapshot),
33
+ date: input.date,
34
+ expiresAt,
35
+ });
36
+ }
37
+ /** Marks a claimed candidate and returns its snapshot for the pipeline. */
38
+ claimNext(input) {
39
+ const rows = this.db
40
+ .prepare(`SELECT * FROM candidate_inventory
41
+ WHERE topic = ? AND target_id = ?
42
+ AND status = 'pending'
43
+ AND expires_at >= ?
44
+ ORDER BY first_seen_date ASC, seen_count ASC
45
+ LIMIT ?`)
46
+ .all(input.topic, input.targetId, this.addDaysUtc(input.date, 0), input.reserveSize);
47
+ for (const row of rows) {
48
+ const updated = this.db
49
+ .prepare(`UPDATE candidate_inventory
50
+ SET status = 'selected', attempt_count = attempt_count + 1, updated_at = CURRENT_TIMESTAMP
51
+ WHERE pixiv_id = ? AND work_type = ? AND topic = ? AND target_id = ? AND status = 'pending'`)
52
+ .run(row.pixiv_id, row.work_type, row.topic, row.target_id);
53
+ if (updated.changes > 0)
54
+ return this.toRow(row);
55
+ }
56
+ return null;
57
+ }
58
+ markSubmitted(pixivId, workType, topic, targetId) {
59
+ this.db
60
+ .prepare(`UPDATE candidate_inventory
61
+ SET status = 'submitted', updated_at = CURRENT_TIMESTAMP
62
+ WHERE pixiv_id = ? AND work_type = ? AND topic = ? AND target_id = ?`)
63
+ .run(pixivId, workType, topic, targetId);
64
+ }
65
+ markFiltered(pixivId, workType, topic, targetId) {
66
+ this.db
67
+ .prepare(`UPDATE candidate_inventory
68
+ SET status = 'filtered', updated_at = CURRENT_TIMESTAMP
69
+ WHERE pixiv_id = ? AND work_type = ? AND topic = ? AND target_id = ?`)
70
+ .run(pixivId, workType, topic, targetId);
71
+ }
72
+ markSelectedBackToPending(pixivId, workType, topic, targetId) {
73
+ this.db
74
+ .prepare(`UPDATE candidate_inventory
75
+ SET status = 'pending', updated_at = CURRENT_TIMESTAMP
76
+ WHERE pixiv_id = ? AND work_type = ? AND topic = ? AND target_id = ? AND status = 'selected'`)
77
+ .run(pixivId, workType, topic, targetId);
78
+ }
79
+ countPending(topic, targetId) {
80
+ const rows = this.db
81
+ .prepare(`SELECT COUNT(*) AS n FROM candidate_inventory WHERE topic = ? AND target_id = ? AND status = 'pending' AND expires_at >= ?`)
82
+ .get(topic, targetId, this.todayUtc());
83
+ return Number(rows?.n ?? 0);
84
+ }
85
+ pendingSummary(topic, targetId) {
86
+ const row = this.db
87
+ .prepare(`SELECT COUNT(*) AS n, MIN(first_seen_date) AS oldest
88
+ FROM candidate_inventory
89
+ WHERE topic = ? AND target_id = ? AND status = 'pending' AND expires_at >= ?`)
90
+ .get(topic, targetId, this.todayUtc());
91
+ return { count: Number(row?.n ?? 0), oldestSeenDate: row?.oldest ?? null };
92
+ }
93
+ /** Sweep rows past maxAgeDays; idempotent per scheduled scan. */
94
+ evictExpired(topic, targetId) {
95
+ const info = this.db
96
+ .prepare(`UPDATE candidate_inventory
97
+ SET status = 'expired', updated_at = CURRENT_TIMESTAMP
98
+ WHERE topic = ? AND target_id = ? AND status IN ('pending','selected') AND expires_at < ?`)
99
+ .run(topic, targetId, this.todayUtc());
100
+ return info.changes;
101
+ }
102
+ toRow(row) {
103
+ return {
104
+ pixivId: String(row.pixiv_id),
105
+ workType: row.work_type,
106
+ topic: row.topic,
107
+ targetId: row.target_id,
108
+ status: row.status,
109
+ snapshotJson: row.snapshot_json,
110
+ firstSeenDate: row.first_seen_date,
111
+ lastSeenDate: row.last_seen_date,
112
+ seenCount: Number(row.seen_count ?? 1),
113
+ attemptCount: Number(row.attempt_count ?? 0),
114
+ expiresAt: row.expires_at,
115
+ createdAt: row.created_at,
116
+ updatedAt: row.updated_at,
117
+ };
118
+ }
119
+ todayUtc() {
120
+ return new Date().toISOString().slice(0, 10);
121
+ }
122
+ addDaysUtc(date, days) {
123
+ const d = new Date(`${date}T00:00:00.000Z`);
124
+ d.setUTCDate(d.getUTCDate() + days);
125
+ return d.toISOString().slice(0, 10);
126
+ }
127
+ }
128
+ exports.CandidateInventoryRepository = CandidateInventoryRepository;
129
+ //# sourceMappingURL=CandidateInventoryRepository.js.map
@@ -62,6 +62,11 @@ export interface SlotItemRecord {
62
62
  terminalReasonCode: string | null;
63
63
  /** User-facing business message for the terminal reason. */
64
64
  terminalReasonMessage: string | null;
65
+ /**
66
+ * Candidate Supply Report (Phase 1), persisted as JSON on the same cell so
67
+ * the durable outcome and the review-group message can never disagree.
68
+ */
69
+ candidateReport: Record<string, unknown> | null;
65
70
  createdAt: string;
66
71
  updatedAt: string;
67
72
  completedAt: string | null;
@@ -172,6 +177,12 @@ export declare class SlotRepository extends BaseRepository {
172
177
  * a later reasoned verdict.
173
178
  */
174
179
  setCellTerminalReason(slotId: string, targetId: string, code: string, message: string): void;
180
+ /**
181
+ * Persist the Phase 1 candidate-supply funnel for a cell (JSON). Idempotent;
182
+ * recovery re-rolls overwrite with the newer observed funnel. A malformed
183
+ * payload must never block the terminal transition, so it is sanitised here.
184
+ */
185
+ setCellCandidateReport(slotId: string, targetId: string, report: Record<string, unknown> | null): void;
175
186
  /**
176
187
  * Transition a cell with FSM validation. Never downgrades a confirmed cell;
177
188
  * an illegal transition throws rather than silently corrupting state.
@@ -266,6 +266,22 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
266
266
  WHERE slot_id = @slotId AND target_id = @targetId`)
267
267
  .run({ slotId, targetId, code: code.slice(0, 64), message: message.slice(0, 400) });
268
268
  }
269
+ /**
270
+ * Persist the Phase 1 candidate-supply funnel for a cell (JSON). Idempotent;
271
+ * recovery re-rolls overwrite with the newer observed funnel. A malformed
272
+ * payload must never block the terminal transition, so it is sanitised here.
273
+ */
274
+ setCellCandidateReport(slotId, targetId, report) {
275
+ const json = report == null ? null : JSON.stringify(report);
276
+ if (json != null && json.length > 4000) {
277
+ throw new Error('candidate_report exceeds 4000 chars');
278
+ }
279
+ this.db
280
+ .prepare(`UPDATE schedule_slot_items
281
+ SET candidate_report = @report, updated_at = CURRENT_TIMESTAMP
282
+ WHERE slot_id = @slotId AND target_id = @targetId`)
283
+ .run({ slotId, targetId, report: json });
284
+ }
269
285
  /**
270
286
  * Transition a cell with FSM validation. Never downgrades a confirmed cell;
271
287
  * an illegal transition throws rather than silently corrupting state.
@@ -441,6 +457,7 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
441
457
  lastError: row.last_error,
442
458
  terminalReasonCode: row.terminal_reason_code ?? null,
443
459
  terminalReasonMessage: row.terminal_reason_message ?? null,
460
+ candidateReport: row.candidate_report ? JSON.parse(row.candidate_report) : null,
444
461
  createdAt: row.created_at,
445
462
  updatedAt: row.updated_at,
446
463
  completedAt: row.completed_at,
@@ -9,6 +9,8 @@ export interface TopicSelection {
9
9
  dedupedCount: number;
10
10
  acceptedCount: number;
11
11
  aiExcludedCount: number;
12
+ /** Works seen more than once across the topic tag space (recorded, dropped). */
13
+ duplicateRemovedCount: number;
12
14
  }
13
15
  /**
14
16
  * Resolves a topic to a tag space, collects that day's works across the tags,
@@ -50,6 +50,7 @@ class TopicPipeline {
50
50
  const byId = new Map();
51
51
  let rawCount = 0;
52
52
  let aiExcludedCount = 0;
53
+ let duplicateRemovedCount = 0;
53
54
  const tagNames = space.tags.map((t) => t.name);
54
55
  for (let i = 0; i < tagNames.length; i++) {
55
56
  if (byId.size >= maxCandidates)
@@ -65,8 +66,10 @@ class TopicPipeline {
65
66
  aiExcludedCount += 1;
66
67
  continue;
67
68
  }
68
- if (byId.has(work.id))
69
+ if (byId.has(work.id)) {
70
+ duplicateRemovedCount += 1;
69
71
  continue;
72
+ }
70
73
  byId.set(work.id, { work, candidate: this.toCandidate(work, contentType) });
71
74
  if (byId.size >= maxCandidates)
72
75
  break;
@@ -107,6 +110,7 @@ class TopicPipeline {
107
110
  dedupedCount,
108
111
  acceptedCount: accepted.length,
109
112
  aiExcludedCount,
113
+ duplicateRemovedCount,
110
114
  },
111
115
  };
112
116
  }
package/dist/version.js CHANGED
@@ -2,5 +2,5 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BUILD = void 0;
4
4
  // GENERATED by scripts/write-version.js — do not edit manually.
5
- exports.BUILD = { version: '2.28.1', commit: '1f5f0c240a38' };
5
+ exports.BUILD = { version: '2.30.0', commit: '153116bd9b08' };
6
6
  //# sourceMappingURL=version.js.map