feat(content-ingestor): Implement content classification logic and unit tests in classifier.ts and classifier.spec.ts

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-07 21:36:09 -07:00
parent e9d6a061c9
commit d82ed2a462
2 changed files with 88 additions and 16 deletions

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { normalizeVisionScore } from './classifier.js';
import { CLASSIFY_RUBRIC, interpretVisionResult } from './classification.js';
/**
* Pins the RAW normalized boundary against model-boss's REAL contrastive endpoint
* `POST /v1/vision/score`, which returns one softmax score per text label across ALL
* labels. The rubric sends [pos0, neg0, pos1, neg1, ]; each dimension is re-normalized
* within its own pair. There is NO server-side /score_image_rubric route (that's an
* MCP-only helper) captured live 2026-06-07 from a real photo. These scores ARE that
* capture (explicit pair, suggestive pair, quality pair):
*/
const LIVE_SCORES = {
scores: [1.1324882507324219e-6, 2.1457672119140625e-6, 0.5029296875, 2.5033950805664062e-6, 0.1175537109375, 0.379638671875],
};
describe('normalizeVisionScore', () => {
it('pairs the flat softmax scores back into per-dimension results', () => {
const r = normalizeVisionScore(LIVE_SCORES, CLASSIFY_RUBRIC);
expect(r.dimensions.map((d) => d.name)).toEqual(['explicit', 'suggestive', 'quality']);
// explicit: 1.13e-6 / (1.13e-6 + 2.15e-6) ≈ 0.345 → below 0.5
expect(r.dimensions[0]?.score).toBeCloseTo(0.345, 2);
expect(r.dimensions[0]?.pass).toBe(false);
// suggestive: 0.503 / (0.503 + ~0) ≈ 1.0 → passes
expect(r.dimensions[1]?.score).toBeCloseTo(1.0, 3);
expect(r.dimensions[1]?.pass).toBe(true);
// quality: 0.1176 / (0.1176 + 0.3796) ≈ 0.236 → below 0.5
expect(r.dimensions[2]?.score).toBeCloseTo(0.236, 2);
expect(r.tags).toEqual([]);
});
it('feeds interpretVisionResult: this real photo → suggestive, not explicit', () => {
const c = interpretVisionResult(normalizeVisionScore(LIVE_SCORES, CLASSIFY_RUBRIC));
expect(c.explicitness).toBe('suggestive');
expect(c.is_explicit).toBe(false);
expect(c.quality_score).toBeCloseTo(0.236, 2);
});
it('defaults missing/short scores to 0 (no NaN, no crash)', () => {
const r = normalizeVisionScore({}, CLASSIFY_RUBRIC);
expect(r.dimensions).toHaveLength(3);
expect(r.dimensions.every((d) => d.score === 0 && d.pass === false)).toBe(true);
});
});

View file

@ -16,9 +16,15 @@ export interface Classifier {
export const CLASSIFIER = Symbol('Classifier');
interface RubricResponse {
dimensions?: Array<{ name?: string; score?: number; pass?: boolean }>;
tags?: string[];
/**
* The model-boss coordinator's contrastive vision primitive (`POST /v1/vision/score`)
* returns one softmax score per supplied text label. Verified live 2026-06-07:
* there is NO server-side `/score_image_rubric` route the rubric is a client-side
* construction (send each dimension's positive+negative label, then re-normalize the
* pair). So we send a flat `texts` array [pos0, neg0, pos1, neg1, ] and pair them back.
*/
interface VisionScoreResponse {
scores?: number[];
}
@Injectable()
@ -33,20 +39,30 @@ export class ModelBossClassifier implements Classifier {
config: ConfigService,
) {
this.baseUrl = config.getOrThrow<string>('MODEL_BOSS_BASE_URL').replace(/\/+$/, '');
this.model = config.get<string>('MODEL_BOSS_VISION_MODEL', 'qwen3-vl-8b-instruct');
// Must be a `vision`-category model (siglip2-so400m, content-moderation-v2). A
// `vlm` (qwen3-vl-8b-instruct) is the wrong category for the contrastive scorer.
this.model = config.get<string>('MODEL_BOSS_VISION_MODEL', 'siglip2-so400m');
this.timeoutMs = config.get<number>('MODEL_BOSS_TIMEOUT_MS', 60_000);
}
async classify(imageBase64: string): Promise<VisionResult> {
// Flatten the rubric into contrastive label pairs, in dimension order.
const texts = CLASSIFY_RUBRIC.flatMap((d) => [d.positive, d.negative]);
try {
const response = await firstValueFrom(
this.http.post<RubricResponse>(
`${this.baseUrl}/score_image_rubric`,
{ model: this.model, image_base64: imageBase64, dimensions: CLASSIFY_RUBRIC },
this.http.post<VisionScoreResponse>(
`${this.baseUrl}/v1/vision/score`,
{
model: this.model,
image_base64: imageBase64,
texts,
mode: 'contrastive',
x_client_id: 'content-ingestor',
},
{ timeout: this.timeoutMs },
),
);
return normalizeRubric(response.data);
return normalizeVisionScore(response.data, CLASSIFY_RUBRIC);
} catch (err: unknown) {
this.logger.error(
'model-boss vision classify failed',
@ -57,12 +73,23 @@ export class ModelBossClassifier implements Classifier {
}
}
/** Normalize model-boss's rubric response into the worker's VisionResult shape. */
export function normalizeRubric(raw: RubricResponse): VisionResult {
const dimensions: VisionDimensionScore[] = (raw.dimensions ?? []).map((d) => ({
name: d.name ?? 'unknown',
score: typeof d.score === 'number' ? d.score : 0,
pass: d.pass === true,
}));
return { dimensions, tags: raw.tags ?? [] };
/**
* Pair the flat softmax `scores` back into per-dimension results. The coordinator's
* softmax spans ALL labels, so each dimension is re-normalized across just its own
* positive/negative pair (matching model-boss-mcp's score_image_rubric tool).
*/
export function normalizeVisionScore(
raw: VisionScoreResponse,
rubric: ReadonlyArray<{ name: string; threshold?: number }>,
): VisionResult {
const scores = raw.scores ?? [];
const dimensions: VisionDimensionScore[] = rubric.map((dim, i) => {
const pos = scores[2 * i] ?? 0;
const neg = scores[2 * i + 1] ?? 0;
const total = pos + neg;
const score = total > 0 ? pos / total : 0;
const threshold = dim.threshold ?? 0.5;
return { name: dim.name, score, pass: score >= threshold };
});
return { dimensions, tags: [] };
}