Files
GMW/services/discord-gateway/src/modules/ai-moderation/batchOutcomeClassifier.ts
T

109 lines
3.4 KiB
TypeScript
Raw Normal View History

/**
* batchOutcomeClassifier.ts
*
* Pure partitioner of the batch worker response (2026-08-25).
*
* Bug history: the batch race guard returned `{ok:true, rows:[]}` when every
* target's attachment upload was still in-flight. The processor classified all
* of them as "incomplete" and fanned out to the individual queue, where the
* guard there requeued + rescheduled at the 250ms debounce — a hot ~300ms loop
* for the entire upload duration (~10 cycles in 3s in prod logs). Root fix:
* the worker now reports `uploadPendingIds` explicitly and this pure function
* partitions the outcome so upload-pending targets NEVER enter the fanout.
*/
export interface BatchRowLike {
id?: string;
ai_status?: string | null;
ai_moderation_flags?: string | null;
}
export interface BatchWorkerResponseLike {
ok?: boolean;
rows?: BatchRowLike[];
/** Explicit race-guard signal from the worker (2026-08-25). */
uploadPendingIds?: string[];
error?: string;
}
/** One target's per-message disposition after a batch attempt. */
export type BatchTargetKind =
| "completed"
| "upload_pending"
| "incomplete"
| "parse_failed"
| "api_failed";
function flagsOf(row: { ai_moderation_flags?: string | null }): string[] {
if (!row.ai_moderation_flags) return [];
try {
const parsed = JSON.parse(row.ai_moderation_flags) as unknown;
return Array.isArray(parsed) ? (parsed as string[]) : [];
} catch {
return [] as string[];
}
}
/**
* Partition the input message ids into per-message dispositions for one batch
* worker response. Pure: no DB/Piscina/logger — unit-testable directly.
*
* Priority per id: explicit uploadPendingIds → completed row → flag-based
* failure kinds → unexplained missing (treated like incomplete).
*/
export function partitionBatchOutcome(
messages: ReadonlyArray<{ id: string }>,
response: BatchWorkerResponseLike,
): Map<string, BatchTargetKind> {
const pendingSet = new Set(response.uploadPendingIds ?? []);
const rowsById = new Map(
(response.rows ?? [])
.filter((r): r is BatchRowLike & { id: string } => Boolean(r?.id))
.map((r) => [r.id, r]),
);
const out = new Map<string, BatchTargetKind>();
for (const msg of messages) {
if (pendingSet.has(msg.id)) {
out.set(msg.id, "upload_pending");
continue;
}
const row = rowsById.get(msg.id);
if (!row) {
// Unexplained drop: LLM silently omitted it. Same retryable bucket as
// analysis_incomplete — never a silent success.
out.set(msg.id, "incomplete");
continue;
}
if (row.ai_status !== "error") {
out.set(msg.id, "completed");
continue;
}
const flags = flagsOf(row);
if (flags.includes("analysis_incomplete")) {
out.set(msg.id, "incomplete");
} else if (flags.includes("analysis_parse_failed")) {
out.set(msg.id, "parse_failed");
} else if (flags.includes("analysis_api_failed")) {
out.set(msg.id, "api_failed");
} else {
out.set(msg.id, "incomplete");
}
}
return out;
}
/**
* Linear backoff ramp for consecutive upload-pending polls:
* poll N (1-based) waits min(base × N, cap). Keeps latency low for fast
* uploads while bounding total polling cost for long uploads.
*/
export function computeUploadPollDelayMs(
consecutivePolls: number,
baseMs: number,
capMs: number,
): number {
const n = Math.max(1, Math.floor(consecutivePolls));
return Math.min(Math.round(baseMs * n), Math.round(capMs));
}