feat(discord-gateway): use Discord CDN for image analysis, uploader archive-only

- mediaDownloader: flip URL candidate order so discord_url is tried
  before uploaded_url (uploaded_url is archive-only fallback)
- ai-analysis-worker: remove upload-pending race guard that blocked
  analysis until Tele upload completed; analysis now runs immediately
  on the Discord CDN URL
- batchProcessor: remove upload-pending defer/poll-backoff logic
- individualFallbackProcessor: remove upload_pending requeue loop
- batchOutcomeClassifier/fallbackResultClassifier: drop upload_pending
  classification (no longer needed)
- tests: update batchOutcomeClassifier + fallbackResultClassifier tests
  to reflect removed upload_pending signal
This commit is contained in:
asepharyana
2026-08-28 22:07:37 +07:00
parent ffbe9959ab
commit 9c9cd8917e
8 changed files with 56 additions and 331 deletions
@@ -1,53 +1,35 @@
// ═══════════════════════════════════════════════════════════════════════════
// partitionBatchOutcome — upload-pending defer vs fanout (2026-08-25)
// ═══════════════════════════════════════════════════════════════════════════
// Bug history: the batch worker's race guard returned {ok:true, rows:[]} while
// attachments were still uploading; every target was classified "incomplete",
// fanned out to the individual queue, requeued there, rescheduled at 250ms —
// a hot ~300ms loop for the whole upload duration (~10 cycles in 3s in prod).
// partitionBatchOutcome — per-message disposition (2026-08-28)
//
// Upload-pending race guard removed: analysis no longer depends on the Tele
// uploader. The worker always runs analysis on the Discord CDN URL directly,
// so there are no upload-pending targets to defer.
import { describe, expect, it } from "vitest";
import {
computeUploadPollDelayMs,
partitionBatchOutcome,
} from "../src/modules/ai-moderation/batchOutcomeClassifier.js";
import { partitionBatchOutcome } from "../src/modules/ai-moderation/batchOutcomeClassifier.js";
const msgs = (...ids: string[]) => ids.map((id) => ({ id }));
describe("partitionBatchOutcome", () => {
it("marks ALL targets upload_pending when the full-batch guard fires", () => {
it("classifies a fully-analyzed batch as completed", () => {
const out = partitionBatchOutcome(msgs("a", "b"), {
ok: true,
rows: [],
uploadPendingIds: ["a", "b"],
});
expect(out.get("a")).toBe("upload_pending");
expect(out.get("b")).toBe("upload_pending");
});
it("never classifies an explicit upload_pending id as incomplete", () => {
// The regression this file exists for: uploadPendingIds must win over the
// missing-row heuristic.
const out = partitionBatchOutcome(msgs("a"), {
ok: true,
rows: [],
uploadPendingIds: ["a"],
});
expect(out.get("a")).not.toBe("incomplete");
expect(out.get("a")).toBe("upload_pending");
});
it("partitions a mixed batch: completed + upload-pending + missing", () => {
const out = partitionBatchOutcome(msgs("ok1", "up1", "gone1"), {
ok: true,
rows: [
{ id: "ok1", ai_status: "clean" },
// up1 has NO row but IS in uploadPendingIds -> deferred, not failed
{ id: "a", ai_status: "clean" },
{ id: "b", ai_status: "flagged" },
],
uploadPendingIds: ["up1"],
});
expect(out.get("ok1")).toBe("completed");
expect(out.get("up1")).toBe("upload_pending");
expect(out.get("gone1")).toBe("incomplete"); // unexplained drop stays retryable
expect(out.get("a")).toBe("completed");
expect(out.get("b")).toBe("completed");
});
it("treats an unexplained missing row as incomplete (retryable)", () => {
const out = partitionBatchOutcome(msgs("a", "gone1"), {
ok: true,
rows: [{ id: "a", ai_status: "clean" }],
// gone1 is missing → incomplete
});
expect(out.get("a")).toBe("completed");
expect(out.get("gone1")).toBe("incomplete");
});
it("routes flag-based failures to their buckets", () => {
@@ -94,17 +76,3 @@ describe("partitionBatchOutcome", () => {
expect(out.get("r")).toBe("completed");
});
});
describe("computeUploadPollDelayMs", () => {
it("ramps linearly and respects the cap", () => {
expect(computeUploadPollDelayMs(1, 1500, 8000)).toBe(1500);
expect(computeUploadPollDelayMs(2, 1500, 8000)).toBe(3000);
expect(computeUploadPollDelayMs(3, 1500, 8000)).toBe(4500);
expect(computeUploadPollDelayMs(9, 1500, 8000)).toBe(8000); // capped
});
it("is safe on degenerate input", () => {
expect(computeUploadPollDelayMs(0, 1500, 8000)).toBe(1500);
expect(computeUploadPollDelayMs(-5, 1000, 4000)).toBe(1000);
});
});