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:
parent
e9d6a061c9
commit
d82ed2a462
2 changed files with 88 additions and 16 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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: [] };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue