From d82ed2a462cecbffb57503c8c56a01c01e92ba0e Mon Sep 17 00:00:00 2001 From: autocommit Date: Sun, 7 Jun 2026 21:36:09 -0700 Subject: [PATCH] =?UTF-8?q?feat(content-ingestor):=20=E2=9C=A8=20Implement?= =?UTF-8?q?=20content=20classification=20logic=20and=20unit=20tests=20in?= =?UTF-8?q?=20classifier.ts=20and=20classifier.spec.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../src/ingest/classifier.spec.ts | 45 ++++++++++++++ .../content-ingestor/src/ingest/classifier.ts | 59 ++++++++++++++----- 2 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 @platform/codebase/@features/content-ingestor/src/ingest/classifier.spec.ts diff --git a/@platform/codebase/@features/content-ingestor/src/ingest/classifier.spec.ts b/@platform/codebase/@features/content-ingestor/src/ingest/classifier.spec.ts new file mode 100644 index 0000000..4c719c7 --- /dev/null +++ b/@platform/codebase/@features/content-ingestor/src/ingest/classifier.spec.ts @@ -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); + }); +}); diff --git a/@platform/codebase/@features/content-ingestor/src/ingest/classifier.ts b/@platform/codebase/@features/content-ingestor/src/ingest/classifier.ts index d8fec51..1583fc8 100644 --- a/@platform/codebase/@features/content-ingestor/src/ingest/classifier.ts +++ b/@platform/codebase/@features/content-ingestor/src/ingest/classifier.ts @@ -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('MODEL_BOSS_BASE_URL').replace(/\/+$/, ''); - this.model = config.get('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('MODEL_BOSS_VISION_MODEL', 'siglip2-so400m'); this.timeoutMs = config.get('MODEL_BOSS_TIMEOUT_MS', 60_000); } async classify(imageBase64: string): Promise { + // 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( - `${this.baseUrl}/score_image_rubric`, - { model: this.model, image_base64: imageBase64, dimensions: CLASSIFY_RUBRIC }, + this.http.post( + `${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: [] }; }