From b6172c7898f6a17b7968e24a96dadec1dd558f62 Mon Sep 17 00:00:00 2001 From: safenestdev Date: Sun, 8 Nov 2026 08:37:10 +0100 Subject: [PATCH 1/2] fix(media): voice stream connects to the served URL; media types match the API - Voice stream connects to /api/v1/safety/voice/stream, sends its initial config once the session is ready, sends context in the wire format (age_group, child_age, resolved platform), and supports video frames with frame_analysis events and visual alerts. - Voice, image, video and document uploads send incident_moderation_enabled. - Image and video take flagRiskTerms and return risk_terms; video takes analysisType and maxFrames; document accepts images and splitBySender. - Synthetic image, audio and video results use the field names the API returns; fileId is forwarded; bypassCache is deprecated as a no-op. - New verifyImageMatch() and getSafetyClassification(). - Type-level tests pin the result shapes. Closes #58 --- README.md | 30 ++- src/client.ts | 125 +++++++++--- src/index.ts | 18 ++ src/types/index.ts | 1 + src/types/media.ts | 134 +++++++++++-- src/types/safety-classification.ts | 73 +++++++ src/types/synthetic.ts | 186 ++++++++++++++---- src/types/voice-stream.ts | 71 +++++-- src/utils/platform.ts | 13 ++ src/voice-stream.ts | 72 ++++--- tests/imageMatchAndClassification.test.ts | 155 +++++++++++++++ tests/mediaUploads.test.ts | 139 ++++++++++++++ tests/types/media.test-d.ts | 136 +++++++++++++ tests/voiceStream.test.ts | 221 ++++++++++++++++++++++ tsconfig.typecheck.json | 12 ++ vitest.config.ts | 7 + 16 files changed, 1276 insertions(+), 117 deletions(-) create mode 100644 src/types/safety-classification.ts create mode 100644 src/utils/platform.ts create mode 100644 tests/imageMatchAndClassification.test.ts create mode 100644 tests/mediaUploads.test.ts create mode 100644 tests/types/media.test-d.ts create mode 100644 tests/voiceStream.test.ts create mode 100644 tsconfig.typecheck.json diff --git a/README.md b/README.md index 5eaa964..da948b1 100644 --- a/README.md +++ b/README.md @@ -597,7 +597,7 @@ for (const page of result.page_results) { } ``` -Available endpoints: `unsafe`, `bullying`, `grooming`, `social-engineering`, `coercive-control`, `radicalisation`, `romance-scam`, `mule-recruitment`. Supported format: PDF only (max 50MB, 100 pages). +Available endpoints: `unsafe`, `bullying`, `grooming`, `social-engineering`, `coercive-control`, `radicalisation`, `romance-scam`, `mule-recruitment`. Supported formats: PDF (max 50MB, 100 pages), or a PNG, JPEG or WebP image read as a one-page document. For a messaging screenshot, pass `splitBySender: true` to judge the last received message with the earlier ones as history. #### `voiceStream(config?, handlers?)` @@ -626,6 +626,17 @@ console.log('Score:', summary.overall_risk_score) console.log('Full transcript:', summary.transcript) ``` +To analyse video alongside the audio, set `enableVideo` and send JPEG or PNG frames. Each analysed frame arrives as a `frame_analysis` event, and concerning frames also raise an alert with category `visual`: + +```typescript +const session = tuteliq.voiceStream( + { enableVideo: true, frameIntervalSeconds: 5, context: { ageGroup: '13-15', childAge: 14 } }, + { onFrameAnalysis: (e) => console.log('Frame', e.frame_index, e.severity) } +) + +session.sendVideoFrame(jpegFrame) +``` + --- ### Synthetic Content Detection @@ -670,10 +681,10 @@ console.log(result.classification) // 'confirmed_synthetic' console.log(result.confidence) // 0.94 console.log(result.level) // 'critical' -// Vision AI forensic analysis -console.log(result.vision.is_likely_synthetic) // true -console.log(result.vision.artifacts) // ['smooth_skin_texture', 'inconsistent_lighting'] -console.log(result.vision.face_analysis) // "Uncanny valley facial features..." +// Semantic forensic analysis +console.log(result.semantic_analysis.is_likely_synthetic) // true +console.log(result.semantic_analysis.artifacts) // ['smooth_skin_texture', 'inconsistent_lighting'] +console.log(result.semantic_analysis.face_analysis) // "Uncanny valley facial features..." // EXIF/metadata analysis console.log(result.metadata_analysis.has_exif) // false @@ -756,11 +767,10 @@ console.log(result.video.has_audio) // true // Temporal face consistency (detects face-swapped deepfakes) if (result.temporal_consistency) { - console.log(result.temporal_consistency.identity_consistency_score) // 0.4 (low = face drift) - console.log(result.temporal_consistency.landmark_stability_score) // 0.3 (low = jitter) - console.log(result.temporal_consistency.temporal_consistency_score) // 0.35 - console.log(result.temporal_consistency.anomalous_frame_pairs) // [{ frame_a: 3, frame_b: 4, distance: 0.7 }] - console.log(result.temporal_consistency.signals) // ['face_identity_drift', 'landmark_jitter'] + console.log(result.temporal_consistency.identity_consistency) // 0.4 (low = face drift) + console.log(result.temporal_consistency.landmark_stability) // 0.3 (low = jitter) + console.log(result.temporal_consistency.score) // 0.35 + console.log(result.temporal_consistency.anomalous_pairs) // [{ frame_a: 3, frame_b: 4, distance: 0.7 }] } // Lip-sync correlation (detects dubbed deepfakes) diff --git a/src/client.ts b/src/client.ts index e1451e1..3fd53e5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -104,6 +104,10 @@ import { DetectSyntheticVideoInput, SyntheticVideoResult, SyntheticProfile, + VerifyImageMatchInput, + VerifyImageMatchResult, + SafetyClassification, + SafetyClassificationResult, // Customer-managed encryption keys RegisterEncryptionKeyInput, CustomerEncryptionKey, @@ -159,6 +163,7 @@ import { import { withRetry } from './utils/retry.js'; import { createVoiceStream } from './voice-stream.js'; +import { resolvePlatform } from './utils/platform.js'; /** Tuteliq API endpoint - locked to official server */ const API_BASE_URL = 'https://api.tuteliq.ai'; @@ -225,6 +230,12 @@ export function mimeTypeForFilename(filename: string): string | undefined { return ext ? UPLOAD_MIME_TYPES[ext] : undefined; } +/** Base64 for a JSON image field; a string is taken as already encoded. */ +function toBase64(image: Buffer | Uint8Array | string): string { + if (typeof image === 'string') return image; + return Buffer.from(image.buffer, image.byteOffset, image.byteLength).toString('base64'); +} + /** Blob for an upload, typed from the filename so the API accepts it. */ function typedBlob(file: Buffer, filename: string): Blob { const type = mimeTypeForFilename(filename); @@ -372,18 +383,9 @@ export class Tuteliq { } } - private static readonly SDK_IDENTIFIER = 'Node SDK'; - - /** - * Resolves the platform string by appending the SDK identifier. - * - "MyApp" → "MyApp - Node SDK" - * - undefined → "Node SDK" - */ + /** See `resolvePlatform` in utils/platform.ts. */ private static resolvePlatform(platform?: string): string { - if (platform && platform.length > 0) { - return `${platform} - ${Tuteliq.SDK_IDENTIFIER}`; - } - return Tuteliq.SDK_IDENTIFIER; + return resolvePlatform(platform); } /** @@ -1525,6 +1527,35 @@ export class Tuteliq { ); } + /** + * Get this account's platform safety classification: the daily snapshot + * also shown on your dashboard. + * + * An account with no classification yet answers `{ exists: false }`, + * which is not a zero score. With too little activity to classify, the + * classification exists but `score` and `band` are null. + * + * @example + * ```typescript + * const result = await tuteliq.getSafetyClassification() + * if (result.exists && result.classification.score !== null) { + * console.log(result.classification.score, result.classification.band) + * } + * ``` + */ + async getSafetyClassification(): Promise { + try { + const classification = await this.requestWithRetry( + 'GET', + '/api/v1/safety-classification' + ); + return { exists: true, classification }; + } catch (err) { + if (err instanceof NotFoundError) return { exists: false }; + throw err; + } + } + /** * Get current rate limit quota status * @@ -1786,7 +1817,7 @@ export class Tuteliq { /** * Analyze voice/audio for safety concerns * - * Transcribes the audio via Whisper, then runs safety analysis on the transcript. + * Transcribes the audio, then runs safety analysis on the transcript. * * @example * ```typescript @@ -1827,6 +1858,7 @@ export class Tuteliq { formData.append('platform', Tuteliq.resolvePlatform(input.platform)); if (input.childAge != null) formData.append('child_age', String(input.childAge)); if (input.metadata) formData.append('metadata', JSON.stringify(input.metadata)); + if (input.incident_moderation_enabled !== undefined) formData.append('incident_moderation_enabled', String(input.incident_moderation_enabled)); return withRetry( () => this.multipartRequest( @@ -1881,7 +1913,9 @@ export class Tuteliq { if (input.ageGroup) formData.append('age_group', input.ageGroup); formData.append('platform', Tuteliq.resolvePlatform(input.platform)); if (input.metadata) formData.append('metadata', JSON.stringify(input.metadata)); + if (input.incident_moderation_enabled !== undefined) formData.append('incident_moderation_enabled', String(input.incident_moderation_enabled)); if (input.flagProfanity !== undefined) formData.append('flag_profanity', String(input.flagProfanity)); + if (input.flagRiskTerms !== undefined) formData.append('flag_risk_terms', String(input.flagRiskTerms)); return withRetry( () => this.multipartRequest( @@ -2107,13 +2141,17 @@ export class Tuteliq { formData.append('file', input.file, input.filename); } + if (input.analysisType) formData.append('analysis_type', input.analysisType); + if (input.maxFrames != null) formData.append('max_frames', String(input.maxFrames)); if (input.fileId) formData.append('file_id', input.fileId); if (input.external_id) formData.append('external_id', input.external_id); if (input.customer_id) formData.append('customer_id', input.customer_id); if (input.ageGroup) formData.append('age_group', input.ageGroup); formData.append('platform', Tuteliq.resolvePlatform(input.platform)); if (input.metadata) formData.append('metadata', JSON.stringify(input.metadata)); + if (input.incident_moderation_enabled !== undefined) formData.append('incident_moderation_enabled', String(input.incident_moderation_enabled)); if (input.flagProfanity !== undefined) formData.append('flag_profanity', String(input.flagProfanity)); + if (input.flagRiskTerms !== undefined) formData.append('flag_risk_terms', String(input.flagRiskTerms)); return withRetry( () => this.multipartRequest( @@ -2129,10 +2167,13 @@ export class Tuteliq { // ========================================================================= /** - * Analyze a PDF document for safety and compliance concerns. + * Analyze a PDF document, or an image read as a one-page document, for + * safety and compliance concerns. * * Extracts text from each page, runs detection endpoints in parallel, - * and returns per-page results with an overall risk assessment. + * and returns per-page results with an overall risk assessment. For a + * messaging screenshot, set `splitBySender` to judge the last received + * message with the rest as history. * Zero-retention: no document data is stored after processing. * * @example @@ -2152,7 +2193,7 @@ export class Tuteliq { */ async analyzeDocument(input: AnalyzeDocumentInput): Promise { if (!input.file) { - throw new ValidationError('PDF file is required'); + throw new ValidationError('Document file is required'); } if (!input.filename) { throw new ValidationError('Filename is required'); @@ -2174,7 +2215,9 @@ export class Tuteliq { if (input.language) formData.append('language', input.language); formData.append('platform', Tuteliq.resolvePlatform(input.platform)); if (input.supportThreshold) formData.append('support_threshold', input.supportThreshold); + if (input.splitBySender) formData.append('split_by_sender', 'true'); if (input.metadata) formData.append('metadata', JSON.stringify(input.metadata)); + if (input.incident_moderation_enabled !== undefined) formData.append('incident_moderation_enabled', String(input.incident_moderation_enabled)); return withRetry( () => this.multipartRequest( @@ -2453,7 +2496,6 @@ export class Tuteliq { ...(input.customer_id && { customer_id: input.customer_id }), ...(input.metadata && { metadata: input.metadata }), ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), - ...(input.bypassCache && { bypass_cache: true }), ...(Object.keys(options).length > 0 && { options }), } ); @@ -2475,7 +2517,7 @@ export class Tuteliq { * }) * * console.log(result.classification) // 'confirmed_synthetic' - * console.log(result.vision.artifacts) // ['uniform skin texture...'] + * console.log(result.semantic_analysis.artifacts) // ['uniform skin texture...'] * console.log(result.metadata_analysis.has_camera) // false * console.log(result.provenance?.ai_tool) // 'DALL-E 3' * ``` @@ -2500,9 +2542,9 @@ export class Tuteliq { if (input.language) formData.append('language', input.language); if (input.external_id) formData.append('external_id', input.external_id); if (input.customer_id) formData.append('customer_id', input.customer_id); + if (input.fileId) formData.append('file_id', input.fileId); formData.append('platform', Tuteliq.resolvePlatform(input.platform)); if (input.metadata) formData.append('metadata', JSON.stringify(input.metadata)); - if (input.bypassCache) formData.append('bypass_cache', 'true'); return withRetry( () => this.multipartRequest( @@ -2553,9 +2595,9 @@ export class Tuteliq { if (input.language) formData.append('language', input.language); if (input.external_id) formData.append('external_id', input.external_id); if (input.customer_id) formData.append('customer_id', input.customer_id); + if (input.fileId) formData.append('file_id', input.fileId); formData.append('platform', Tuteliq.resolvePlatform(input.platform)); if (input.metadata) formData.append('metadata', JSON.stringify(input.metadata)); - if (input.bypassCache) formData.append('bypass_cache', 'true'); return withRetry( () => this.multipartRequest( @@ -2583,7 +2625,7 @@ export class Tuteliq { * }) * * console.log(result.classification) // 'suspected_synthetic' - * console.log(result.temporal_consistency?.identity_consistency_score) // 0.42 + * console.log(result.temporal_consistency?.identity_consistency) // 0.42 * console.log(result.lip_sync?.correlation) // 0.21 * ``` */ @@ -2608,9 +2650,9 @@ export class Tuteliq { if (input.language) formData.append('language', input.language); if (input.external_id) formData.append('external_id', input.external_id); if (input.customer_id) formData.append('customer_id', input.customer_id); + if (input.fileId) formData.append('file_id', input.fileId); formData.append('platform', Tuteliq.resolvePlatform(input.platform)); if (input.metadata) formData.append('metadata', JSON.stringify(input.metadata)); - if (input.bypassCache) formData.append('bypass_cache', 'true'); return withRetry( () => this.multipartRequest( @@ -2621,6 +2663,45 @@ export class Tuteliq { ); } + /** + * Run image checks in one call: face match against a reference image, + * age estimate, explicit-content and skin-exposure checks, and + * synthetic-image detection. Images are processed in memory and never + * stored. + * + * @example + * ```typescript + * import { readFileSync } from 'fs' + * + * const result = await tuteliq.verifyImageMatch({ + * image: readFileSync('selfie.jpg'), + * referenceImage: readFileSync('profile.jpg'), + * checks: ['face_match', 'age'], + * }) + * + * console.log(result.face_match?.matched) // true + * console.log(result.age?.estimated_age) // 24 + * ``` + */ + async verifyImageMatch(input: VerifyImageMatchInput): Promise { + if (!input.image || input.image.length === 0) { + throw new ValidationError('Image is required'); + } + + return this.requestWithRetry( + 'POST', '/api/v1/verify-image-match', + { + image: toBase64(input.image), + ...(input.referenceImage && { reference_image: toBase64(input.referenceImage) }), + ...(input.checks && { checks: input.checks }), + ...(input.external_id && { external_id: input.external_id }), + ...(input.customer_id && { customer_id: input.customer_id }), + ...(input.metadata && { metadata: input.metadata }), + ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), + } + ); + } + /** * Get the synthetic content profile for a customer. * @@ -2636,7 +2717,7 @@ export class Tuteliq { * console.log(profile.synthetic_count) // 12 * ``` * - * @deprecated The backing API route was never shipped and this method + * @deprecated The backing API route has been removed and this method * returns a 404 at runtime. It will be removed in the next major version. */ async getSyntheticProfile(customerId: string): Promise { diff --git a/src/index.ts b/src/index.ts index 9e8d392..c1eafa3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -117,6 +117,17 @@ export type { DocumentPageResult, DocumentPageEndpointResult, DocumentFlaggedPage, + MediaRiskTerms, + ImageMatchCheck, + VerifyImageMatchInput, + VerifyImageMatchResult, + + // Safety classification types + SafetyClassification, + SafetyClassificationBand, + SafetyClassificationComponent, + SafetyClassificationEvidence, + SafetyClassificationResult, // Webhook types Webhook, @@ -186,6 +197,9 @@ export type { VoiceTranscriptionEvent, VoiceTranscriptionSegment, VoiceAlertEvent, + VoiceFrameAnalysisEvent, + VoiceStreamAnalysisType, + VoiceStreamServerConfig, VoiceSessionSummaryEvent, VoiceConfigUpdatedEvent, VoiceErrorEvent, @@ -220,6 +234,10 @@ export type { DetectSyntheticAudioInput, SyntheticAudioResult, AudioStats, + AudioForensics, + SyntheticTranscription, + ScreenshotDetection, + DeepImageAnalysis, DetectSyntheticVideoInput, SyntheticVideoResult, TemporalConsistency, diff --git a/src/types/index.ts b/src/types/index.ts index 8d5922b..03e9bf4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,6 +18,7 @@ export * from './incidents.js'; export * from './automation.js'; export * from './settings.js'; export * from './intelligence.js'; +export * from './safety-classification.js'; // ============================================================================= // Common Types diff --git a/src/types/media.ts b/src/types/media.ts index a981992..0a14487 100644 --- a/src/types/media.ts +++ b/src/types/media.ts @@ -97,6 +97,10 @@ export interface VoiceAnalysisResult { * only; never contains the transcript, image description or extracted text. */ rationale: string; + /** Detected or declared language code */ + language?: string; + /** Language support maturity */ + language_status?: 'stable' | 'beta'; /** Number of credits consumed by this request */ credits_used?: number; /** Echo of provided external_id */ @@ -107,6 +111,14 @@ export interface VoiceAnalysisResult { metadata?: Record; } +/** + * Drug/violence term list result over OCR text. Present only when + * `flagRiskTerms` (or the account-level `default_flag_risk_terms` setting) + * is true and the media contained OCR text. Additive: never affects the + * verdict. Purely lexical, so it can fire on benign uses of a term. + */ +export type MediaRiskTerms = UnsafeResult['risk_terms']; + // ============================================================================= // Video Analysis // ============================================================================= @@ -140,6 +152,10 @@ export interface AnalyzeVideoInput extends TrackingFields { file: Buffer | Blob | File; /** Original filename (e.g., "clip.mp4") */ filename: string; + /** Analysis to run on the sampled frames (default `'all'`) */ + analysisType?: 'unsafe' | 'all'; + /** Frames to sample across the video (default 10, at most 20) */ + maxFrames?: number; /** Customer-provided file reference ID (echoed in response) */ fileId?: string; /** Age group for calibrated analysis */ @@ -166,6 +182,13 @@ export interface AnalyzeVideoInput extends TrackingFields { * or after 2026-08-26.** */ flagProfanity?: boolean; + /** + * Additive, deterministic drug/violence term list over the OCR text + * across frames. When true, adds a `risk_terms` field to the response; + * never affects the verdict. Explicit `true`/`false` overrides your + * account's `default_flag_risk_terms` setting; omit to use the default. + */ + flagRiskTerms?: boolean; } export interface VideoAnalysisResult { @@ -229,6 +252,8 @@ export interface VideoAnalysisResult { * `overall_severity`, or `recommended_action`. */ profanity?: { detected: boolean; matches: string[] } | null; + /** See `MediaRiskTerms`. Aggregated over OCR text across frames. */ + risk_terms?: MediaRiskTerms; } // ============================================================================= @@ -283,6 +308,13 @@ export interface AnalyzeImageInput extends TrackingFields { * account default. **Requires the API deployed on or after 2026-08-25.** */ flagProfanity?: boolean; + /** + * Additive, deterministic drug/violence term list over the image's OCR + * text. When true, adds a `risk_terms` field to the response; never + * affects the verdict. Explicit `true`/`false` overrides your account's + * `default_flag_risk_terms` setting; omit to use the default. + */ + flagRiskTerms?: boolean; } export interface ImageAnalysisResult { @@ -338,6 +370,8 @@ export interface ImageAnalysisResult { * `recommended_action`, or any `text_analysis` result. */ profanity?: { detected: boolean; matches: string[] } | null; + /** See `MediaRiskTerms`. */ + risk_terms?: MediaRiskTerms; } // ============================================================================= @@ -356,9 +390,12 @@ export type DocumentEndpointName = | 'mule-recruitment'; export interface AnalyzeDocumentInput extends TrackingFields { - /** PDF file — Buffer, Blob, or File */ + /** + * PDF, or a PNG, JPEG or WebP image read as a one-page document (Buffer, + * Blob, or File). The type is decided from the file's bytes. + */ file: Buffer | Blob | File; - /** Original filename (e.g., "report.pdf") */ + /** Original filename (e.g., "report.pdf", "chat.png") */ filename: string; /** Detection endpoints to run on each page. Defaults to ['unsafe', 'coercive-control', 'radicalisation']. */ endpoints?: DocumentEndpointName[]; @@ -378,6 +415,12 @@ export interface AnalyzeDocumentInput extends TrackingFields { platform?: string; /** Minimum severity to include crisis helplines. Default: "high". */ supportThreshold?: 'low' | 'medium' | 'high' | 'critical'; + /** + * Images only. For a messaging screenshot, splits it into messages with + * the side each came from, so the last received message is judged with + * the rest as history. Costs one extra vision call. + */ + splitBySender?: boolean; } export interface DocumentExtractionSummary { @@ -410,17 +453,14 @@ export interface DocumentPageEndpointResult { evidence: Array<{ text: string; tactic: string; weight: number }>; /** * Recommended action for this endpoint on this page, as a stable enum. - * Safe to switch on; human guidance is in `action_detail`. + * Safe to switch on. */ recommended_action: RecommendedAction; - /** - * Optional human-readable expansion of `recommended_action`, for a - * moderator UI. Free text: do not branch on it. - */ + /** @deprecated Not returned per page; never present. */ action_detail?: string; /** Human-readable rationale */ rationale: string; - /** Detected language */ + /** @deprecated Not returned per page; use the top-level `language`. */ detected_language?: string; } @@ -453,9 +493,9 @@ export interface DocumentFlaggedPage { } export interface DocumentAnalysisResult { - /** Customer-provided file reference (if provided) */ - file_id?: string; - /** SHA-256 hash of the uploaded PDF for chain-of-custody verification */ + /** Your file reference, or the uploaded filename when none was given */ + file_id: string; + /** SHA-256 hash of the uploaded file for chain-of-custody verification */ document_hash: string; /** Total pages in the document */ total_pages: number; @@ -490,3 +530,75 @@ export interface DocumentAnalysisResult { /** Echo of provided metadata */ metadata?: Record; } + +// ============================================================================= +// Image Match (composite image checks) +// ============================================================================= + +/** Checks `verifyImageMatch` can run. */ +export type ImageMatchCheck = 'face_match' | 'age' | 'forensic' | 'synthetic'; + +export interface VerifyImageMatchInput extends TrackingFields { + /** + * The image to check (max 15 MB). Bytes are base64-encoded by the SDK; a + * string is sent as-is and must already be base64 (a data URL is accepted). + */ + image: Buffer | Uint8Array | string; + /** Reference image for `face_match`; without it that check is skipped */ + referenceImage?: Buffer | Uint8Array | string; + /** Checks to run (default: all) */ + checks?: ImageMatchCheck[]; +} + +export interface VerifyImageMatchResult { + /** Response schema version */ + schema_version: string; + /** Checks that actually ran (`face_match` is left out without a reference image) */ + checks_run: ImageMatchCheck[]; + /** Face comparison against the reference image */ + face_match?: { + /** Whether the two faces match */ + matched: boolean; + /** Face similarity (0.0-1.0), absent when no face was found */ + similarity?: number; + /** Why matching failed (e.g. `no_face_detected`) */ + reason?: string; + }; + /** Age estimate for the image, or null when no face was detected */ + age?: { + /** Estimated age in years */ + estimated_age: number; + /** Face-detection confidence (0.0-1.0) */ + confidence: number; + } | null; + /** Explicit-content and skin-exposure checks */ + forensic?: { + /** Whether the image was classified as explicit */ + nsfw: boolean; + /** Explicit-content score (0.0-1.0) */ + nsfw_confidence?: number; + /** Coarse skin-exposure level */ + skin_exposure_level?: 'low' | 'moderate' | 'high'; + }; + /** Synthetic-image checks; each field is absent when its check could not run */ + synthetic?: { + /** Classifier verdict */ + is_synthetic?: boolean; + /** Probability the image is synthetic (0.0-1.0) */ + synthetic_probability?: number; + /** A C2PA / Content Credentials manifest is embedded */ + has_c2pa?: boolean; + /** The C2PA manifest declares AI generation */ + is_ai_generated?: boolean; + }; + /** Credits charged for this call */ + credits_used: number; + /** Total processing time in milliseconds */ + processing_ms: number; + /** Echo of provided external_id */ + external_id?: string; + /** Echo of provided customer_id */ + customer_id?: string; + /** Echo of provided metadata */ + metadata?: Record; +} diff --git a/src/types/safety-classification.ts b/src/types/safety-classification.ts new file mode 100644 index 0000000..ba8b01a --- /dev/null +++ b/src/types/safety-classification.ts @@ -0,0 +1,73 @@ +// ============================================================================= +// Platform Safety Classification +// ============================================================================= + +/** Classification band, from strongest to weakest. */ +export type SafetyClassificationBand = 'leading' | 'established' | 'progressing' | 'needs_attention'; + +export interface SafetyClassificationComponent { + /** Component identifier */ + key: 'reviewCoverage' | 'responseTime' | 'openQueueAge' | 'detectionCoverage' | 'integrationHealth'; + /** Display label */ + label: string; + /** Points earned */ + points: number; + /** Maximum points */ + max: number; + /** Improvement hint, present when the component is below full marks */ + tip: string | null; +} + +/** The inputs the score was computed from. Contains no personal data. */ +export interface SafetyClassificationEvidence { + /** Real incidents counted (test and sandbox traffic excluded) */ + incident_count: number; + /** Incidents that have been reviewed */ + reviewed_count: number; + /** Median minutes to review, when known */ + median_review_minutes: number | null; + /** Age in days of the oldest open incident (0 when the queue is empty) */ + oldest_open_days: number | null; + /** Content types with live detection */ + modalities_live: string[]; + /** Content types the platform offers; scoring is measured against these */ + applicable_modalities: string[]; + /** Number of connected platforms */ + connected_platforms: number; + /** When the evidence was gathered */ + calculated_at: string; +} + +/** The account's daily safety classification snapshot. */ +export interface SafetyClassification { + /** UTC date of the daily snapshot */ + date: string; + /** 0-100, or null when `insufficient_data` is true. Never treat null as zero. */ + score: number | null; + /** Band, or null when `insufficient_data` is true */ + band: SafetyClassificationBand | null; + /** True when there is too little activity to classify yet; not a bad score */ + insufficient_data: boolean; + /** Why the account is not yet classified */ + reason: string | null; + /** Always `all_time` */ + period: 'all_time'; + /** Score breakdown */ + components: SafetyClassificationComponent[]; + /** Inputs the score was computed from */ + evidence: SafetyClassificationEvidence; + /** When the score was computed */ + calculated_at: string; + /** When the snapshot was stored */ + updated_at: string; + /** Shown beside the figure: it is not a certification or compliance determination */ + disclaimer: string; +} + +/** + * Answer from `getSafetyClassification`. `exists: false` means the account + * has no classification yet, which is not a zero score. + */ +export type SafetyClassificationResult = + | { exists: true; classification: SafetyClassification } + | { exists: false }; diff --git a/src/types/synthetic.ts b/src/types/synthetic.ts index f64f3c2..65461c2 100644 --- a/src/types/synthetic.ts +++ b/src/types/synthetic.ts @@ -1,7 +1,7 @@ import { TrackingFields } from './index.js'; import { ContextInput, RecommendedAction } from './safety.js'; import { DetectionCategory, DetectionEvidence, AgeCalibration } from './detection.js'; -import { TranscriptionResult } from './media.js'; +import { TranscriptionResult, TranscriptionSegment } from './media.js'; // ============================================================================= // Synthetic Content Classification @@ -25,13 +25,7 @@ export interface DetectSyntheticTextInput extends TrackingFields { context?: ContextInput; /** Minimum severity to show crisis support resources (default: 'high') */ supportThreshold?: 'low' | 'medium' | 'high' | 'critical'; - /** - * When true, skip the verdict cache for both read and write — the request - * runs a fresh evaluation and the result is not stored. Cache keys are - * SHA-256 of the input bytes (one-way; we never store the content itself); - * cached verdicts expire after 1 hour. Use for forensic re-tests, - * regression probes, or any workflow that needs a guaranteed fresh result. - */ + /** @deprecated No-op: there is no verdict cache; every request is analysed fresh. */ bypassCache?: boolean; } @@ -58,13 +52,10 @@ export interface SyntheticTextResult { age_calibration?: AgeCalibration; /** * Recommended action, as a stable enum ordered weakest to strongest. - * Safe to switch on; human guidance is in `action_detail`. + * Safe to switch on. */ recommended_action: RecommendedAction; - /** - * Optional human-readable expansion of `recommended_action`, for a - * moderator UI. Free text: do not branch on it. - */ + /** @deprecated Not returned by the text endpoint; never present. */ action_detail?: string; /** Human-readable rationale */ rationale: string; @@ -107,8 +98,12 @@ export interface DetectSyntheticImageInput extends TrackingFields { * "social_media". */ platform?: string; - /** Skip verdict cache (both read and write). See bypassCache on DetectSyntheticTextInput. */ + /** Customer-provided file reference ID (echoed in response) */ + fileId?: string; + /** @deprecated No-op: there is no verdict cache; every request is analysed fresh. */ bypassCache?: boolean; + /** @deprecated Not read by this endpoint; has no effect. */ + incident_moderation_enabled?: boolean; } /** Vision AI forensic analysis results */ @@ -181,6 +176,27 @@ export interface KnownSyntheticMatch { category: string; } +/** The image looks like a screen capture rather than a camera photo. */ +export interface ScreenshotDetection { + is_screenshot: true; + /** Confidence (0.0-1.0) */ + confidence: number; + /** Signals behind the assessment */ + signals: string[]; +} + +/** Learned whole-image classifier result. */ +export interface DeepImageAnalysis { + /** Whether the classifier judged the image synthetic */ + is_synthetic: boolean; + /** Probability the image is synthetic (0.0-1.0) */ + synthetic_probability: number; + /** Confidence (0.0-1.0) */ + confidence: number; + /** Time taken in milliseconds */ + processing_ms: number; +} + export interface SyntheticImageResult { /** Endpoint name */ endpoint: string; @@ -212,10 +228,14 @@ export interface SyntheticImageResult { action_detail?: string; /** Human-readable rationale */ rationale: string; + /** Age calibration details */ + age_calibration?: AgeCalibration; /** Input type */ input_type: 'image'; - /** Vision AI forensic analysis */ - vision: SyntheticVisionResult; + /** Semantic forensic analysis of the image */ + semantic_analysis: SyntheticVisionResult; + /** @deprecated Never returned; the same analysis is in `semantic_analysis`. */ + vision?: SyntheticVisionResult; /** EXIF/metadata analysis */ metadata_analysis: MetadataAnalysis; /** C2PA Content Credentials (present only when a C2PA manifest is found) */ @@ -226,6 +246,10 @@ export interface SyntheticImageResult { perceptual_hash: string; /** Known synthetic match (present only when pHash matches a known image) */ known_synthetic_match?: KnownSyntheticMatch; + /** Present only when the image looks like a screen capture rather than a photo */ + screenshot_detection?: ScreenshotDetection; + /** Learned whole-image classifier result (present when that analysis ran) */ + deep_image_analysis?: DeepImageAnalysis; /** Processing time in milliseconds */ processing_time_ms?: number; /** Language code */ @@ -238,6 +262,10 @@ export interface SyntheticImageResult { external_id?: string; /** Echo of provided customer_id */ customer_id?: string; + /** Echo of provided metadata (non-JSON metadata comes back as `{ raw }`) */ + metadata?: Record; + /** Echo of provided fileId */ + file_id?: string; } // ============================================================================= @@ -261,24 +289,59 @@ export interface DetectSyntheticAudioInput extends TrackingFields { * "social_media". */ platform?: string; - /** Skip verdict cache (both read and write). See bypassCache on DetectSyntheticTextInput. */ + /** Customer-provided file reference ID (echoed in response) */ + fileId?: string; + /** @deprecated No-op: there is no verdict cache; every request is analysed fresh. */ bypassCache?: boolean; + /** @deprecated Not read by this endpoint; has no effect. */ + incident_moderation_enabled?: boolean; } /** Quantitative audio statistics from spectral analysis */ export interface AudioStats { - /** Mean RMS level in dB */ + /** RMS level in dB */ + rms_level: number; + /** @deprecated Never returned; the RMS level is in `rms_level`. */ rms_mean?: number; /** Peak RMS level in dB */ - rms_peak?: number; + rms_peak: number; /** Dynamic range in dB */ - dynamic_range?: number; + dynamic_range: number; /** Ratio of silent segments (0.0-1.0) */ - silence_ratio?: number; + silence_ratio: number; + /** Number of peaks */ + peak_count: number; /** Flat factor (0.0-1.0, higher = more uniform/synthetic) */ - flat_factor?: number; + flat_factor: number; /** DC offset */ - dc_offset?: number; + dc_offset: number; + /** Smoothness of spectral change over time */ + mfcc_delta_smoothness?: number; + /** Regularity of pitch */ + f0_regularity?: number; + /** Formant bandwidth */ + formant_bandwidth?: number; +} + +/** + * Transcription on a synthetic-content result. `segments` is absent when + * the audio had no speech, and on video results. + */ +export interface SyntheticTranscription extends Omit { + /** Timestamped segments, when returned */ + segments?: TranscriptionSegment[]; +} + +/** Signal-processing forensics on the waveform. */ +export interface AudioForensics { + /** Named measurements; the set of keys may change */ + metrics: Record; + /** Signals that point to synthetic audio */ + signals: string[]; + /** Contribution to the overall confidence */ + confidence_boost: number; + /** Extra detail, when returned; shape not guaranteed */ + details?: unknown; } export interface SyntheticAudioResult { @@ -310,14 +373,20 @@ export interface SyntheticAudioResult { action_detail?: string; /** Human-readable rationale */ rationale: string; + /** Evidence excerpts */ + evidence?: DetectionEvidence[]; + /** Age calibration details */ + age_calibration?: AgeCalibration; /** Input type */ input_type: 'audio'; /** Transcription result */ - transcription?: TranscriptionResult; + transcription?: SyntheticTranscription; /** Quantitative audio statistics */ audio_stats?: AudioStats; /** Spectral analysis signals (e.g., low dynamic range, frequency uniformity) */ spectral_signals?: string[]; + /** Signal-processing forensics on the waveform */ + audio_forensics?: AudioForensics; /** Processing time in milliseconds */ processing_time_ms?: number; /** Language code */ @@ -330,6 +399,10 @@ export interface SyntheticAudioResult { external_id?: string; /** Echo of provided customer_id */ customer_id?: string; + /** Echo of provided metadata (not returned when the audio had no speech) */ + metadata?: Record; + /** Echo of provided fileId (not returned when the audio had no speech) */ + file_id?: string; } // ============================================================================= @@ -355,30 +428,46 @@ export interface DetectSyntheticVideoInput extends TrackingFields { * "social_media". */ platform?: string; - /** Skip verdict cache (both read and write). See bypassCache on DetectSyntheticTextInput. */ + /** Customer-provided file reference ID (echoed in response) */ + fileId?: string; + /** @deprecated No-op: there is no verdict cache; every request is analysed fresh. */ bypassCache?: boolean; + /** @deprecated Not read by this endpoint; has no effect. */ + incident_moderation_enabled?: boolean; } /** Temporal face consistency across video frames */ export interface TemporalConsistency { - /** Number of frames where faces were detected */ - frames_with_faces: number; - /** Total frames analyzed */ - total_frames: number; + /** Combined temporal consistency score (0.0-1.0) */ + score: number; /** Face identity consistency score (0.0-1.0, higher = more consistent) */ - identity_consistency_score: number; + identity_consistency: number; /** Facial landmark stability score (0.0-1.0, higher = more stable) */ - landmark_stability_score: number; - /** Combined temporal consistency score (0.0-1.0) */ - temporal_consistency_score: number; + landmark_stability: number; + /** Number of frames where faces were detected */ + frames_with_faces: number; /** Frame pairs with anomalous face identity changes */ - anomalous_frame_pairs: Array<{ + anomalous_pairs: Array<{ frame_a: number; frame_b: number; distance: number; }>; - /** Human-readable temporal signals */ - signals: string[]; + /** @deprecated Never returned; use `video.frames_analyzed`. */ + total_frames?: number; + /** @deprecated Never returned; use `identity_consistency`. */ + identity_consistency_score?: number; + /** @deprecated Never returned; use `landmark_stability`. */ + landmark_stability_score?: number; + /** @deprecated Never returned; use `score`. */ + temporal_consistency_score?: number; + /** @deprecated Never returned; use `anomalous_pairs`. */ + anomalous_frame_pairs?: Array<{ + frame_a: number; + frame_b: number; + distance: number; + }>; + /** @deprecated Never returned. */ + signals?: string[]; } /** Audio-visual lip-sync correlation */ @@ -389,8 +478,8 @@ export interface LipSyncResult { has_silent_mouth_movement: boolean; /** Whether voice was detected without mouth movement (> 30% of frames) */ has_voice_without_movement: boolean; - /** Human-readable lip-sync signals */ - signals: string[]; + /** @deprecated Never returned. */ + signals?: string[]; } export interface SyntheticVideoResult { @@ -437,28 +526,45 @@ export interface SyntheticVideoResult { temporal_consistency?: TemporalConsistency; /** Lip-sync correlation analysis */ lip_sync?: LipSyncResult; - /** Transcription result (if audio present) */ - transcription?: TranscriptionResult; + /** Pattern of frame-to-frame drift (omitted when unknown) */ + drift_signature?: 'sawtooth' | 'sinusoidal' | 'stable'; + /** Transcription result (if audio present; no segments) */ + transcription?: SyntheticTranscription; /** Quantitative audio statistics */ audio_stats?: AudioStats; /** Spectral analysis signals */ spectral_signals?: string[]; + /** Signal-processing forensics on the audio track (present when it found signals) */ + audio_forensics?: Omit; + /** Multi-signal summary (present only when there are signals) */ + forensic_signals?: ForensicSignals; + /** Evidence excerpts */ + evidence?: DetectionEvidence[]; + /** Age calibration details */ + age_calibration?: AgeCalibration; /** Processing time in milliseconds */ processing_time_ms?: number; /** Language code */ language?: string; + /** Language support maturity */ + language_status?: 'stable' | 'beta'; /** Credits consumed */ credits_used?: number; /** Echo of provided external_id */ external_id?: string; /** Echo of provided customer_id */ customer_id?: string; + /** Echo of provided metadata (non-JSON metadata comes back as `{ raw }`) */ + metadata?: Record; + /** Echo of provided fileId */ + file_id?: string; } // ============================================================================= // Account-Level Synthetic Profiling // ============================================================================= +/** @deprecated The profile endpoint was removed from the API; see `getSyntheticProfile`. */ export interface SyntheticProfile { /** Customer identifier */ customer_id: string; diff --git a/src/types/voice-stream.ts b/src/types/voice-stream.ts index 094edea..ef43079 100644 --- a/src/types/voice-stream.ts +++ b/src/types/voice-stream.ts @@ -1,19 +1,34 @@ +import type { VisionResult } from './media.js'; + // ============================================================================= // Voice Streaming Types // ============================================================================= +/** Analysis that can run on each flush of a voice stream. */ +export type VoiceStreamAnalysisType = 'bullying' | 'unsafe' | 'grooming' | 'emotions' | 'all'; + /** * Configuration for a voice streaming session. */ export interface VoiceStreamConfig { /** Flush interval in seconds (5-30, default 10) */ intervalSeconds?: number; - /** Analysis types to run on each flush */ - analysisTypes?: Array<'bullying' | 'unsafe' | 'grooming' | 'emotions'>; + /** Analysis types to run on each flush (default `['all']`) */ + analysisTypes?: VoiceStreamAnalysisType[]; + /** + * Analyse video frames sent with `sendVideoFrame()` (default false). + * Each analysed frame produces a `frame_analysis` event. + */ + enableVideo?: boolean; + /** Minimum seconds between analysed video frames (at least 3, default 5) */ + frameIntervalSeconds?: number; /** Additional context for analysis */ context?: { language?: string; ageGroup?: string; + /** Child's age (used for grooming analysis) */ + childAge?: number; + /** @deprecated Not read by the voice stream; has no effect. */ relationship?: string; /** * Your platform/app name (e.g. "MyApp"), used for dashboard @@ -28,13 +43,24 @@ export interface VoiceStreamConfig { // Server → Client Events // ============================================================================= +/** Session configuration as the server applies it. */ +export interface VoiceStreamServerConfig { + interval_seconds: number; + analysis_types: string[]; + enable_video: boolean; + frame_interval_seconds: number; + context?: { + age_group?: string; + language?: string; + platform?: string; + child_age?: number; + }; +} + export interface VoiceReadyEvent { type: 'ready'; session_id: string; - config: { - interval_seconds: number; - analysis_types: string[]; - }; + config: VoiceStreamServerConfig; } export interface VoiceTranscriptionSegment { @@ -52,7 +78,8 @@ export interface VoiceTranscriptionEvent { export interface VoiceAlertEvent { type: 'alert'; - category: string; + /** `visual` alerts come from analysed video frames. */ + category: 'bullying' | 'unsafe' | 'grooming' | 'emotions' | 'visual'; severity: string; risk_score: number; details: Record; @@ -63,18 +90,33 @@ export interface VoiceSessionSummaryEvent { type: 'session_summary'; session_id: string; duration_seconds: number; - overall_risk: string; + overall_risk: 'low' | 'medium' | 'high' | 'critical'; overall_risk_score: number; total_flushes: number; transcript: string; + /** Number of video frames analysed during the session */ + video_frames_analyzed: number; +} + +/** Analysis of one video frame sent on the stream (requires `enableVideo`). */ +export interface VoiceFrameAnalysisEvent { + type: 'frame_analysis'; + session_id: string; + /** Index of the analysed frame within the session */ + frame_index: number; + /** ISO timestamp of the analysis */ + timestamp: string; + /** Vision analysis of the frame */ + vision: VisionResult; + /** Risk score for this frame (0-1) */ + risk_score: number; + /** Severity level for this frame */ + severity: string; } export interface VoiceConfigUpdatedEvent { type: 'config_updated'; - config: { - interval_seconds: number; - analysis_types: string[]; - }; + config: VoiceStreamServerConfig; } export interface VoiceErrorEvent { @@ -88,6 +130,7 @@ export type VoiceStreamEvent = | VoiceReadyEvent | VoiceTranscriptionEvent | VoiceAlertEvent + | VoiceFrameAnalysisEvent | VoiceSessionSummaryEvent | VoiceConfigUpdatedEvent | VoiceErrorEvent; @@ -103,6 +146,8 @@ export interface VoiceStreamHandlers { onTranscription?: (event: VoiceTranscriptionEvent) => void; /** Called when a safety alert is triggered */ onAlert?: (event: VoiceAlertEvent) => void; + /** Called when a video frame has been analysed */ + onFrameAnalysis?: (event: VoiceFrameAnalysisEvent) => void; /** Called when the session ends with a summary */ onSessionSummary?: (event: VoiceSessionSummaryEvent) => void; /** Called when config is updated */ @@ -120,6 +165,8 @@ export interface VoiceStreamHandlers { export interface VoiceStreamSession { /** Send raw audio data (binary frame) */ sendAudio(data: Buffer | Uint8Array): void; + /** Send one JPEG or PNG video frame (requires `enableVideo`) */ + sendVideoFrame(data: Buffer | Uint8Array): void; /** Update the session configuration */ updateConfig(config: VoiceStreamConfig): void; /** Signal end of audio. Resolves with the session summary. */ diff --git a/src/utils/platform.ts b/src/utils/platform.ts new file mode 100644 index 0000000..a9c0c37 --- /dev/null +++ b/src/utils/platform.ts @@ -0,0 +1,13 @@ +const SDK_IDENTIFIER = 'Node SDK'; + +/** + * Resolves the platform string by appending the SDK identifier. + * - "MyApp" → "MyApp - Node SDK" + * - undefined → "Node SDK" + */ +export function resolvePlatform(platform?: string): string { + if (platform && platform.length > 0) { + return `${platform} - ${SDK_IDENTIFIER}`; + } + return SDK_IDENTIFIER; +} diff --git a/src/voice-stream.ts b/src/voice-stream.ts index 58b6948..4b8f955 100644 --- a/src/voice-stream.ts +++ b/src/voice-stream.ts @@ -5,9 +5,38 @@ import type { VoiceStreamEvent, VoiceSessionSummaryEvent, } from './types/voice-stream.js'; +import { resolvePlatform } from './utils/platform.js'; /** WebSocket URL for voice streaming */ -const VOICE_STREAM_URL = 'wss://api.tuteliq.ai/voice/stream'; +const VOICE_STREAM_URL = 'wss://api.tuteliq.ai/api/v1/safety/voice/stream'; + +/** Binary frame prefix the API reads as a video frame (audio goes untagged). */ +const VIDEO_FRAME_PREFIX = 0x01; + +/** + * Builds the `config` message in the API's wire format. `context` is sent + * only when given, or when `withDefaultContext` is set, because the server + * merges it into the session and a bare `{ platform }` would overwrite the + * platform set earlier. + */ +function buildConfigMessage(config: VoiceStreamConfig, withDefaultContext: boolean): Record { + const message: Record = { type: 'config' }; + if (config.intervalSeconds !== undefined) message.interval_seconds = config.intervalSeconds; + if (config.analysisTypes !== undefined) message.analysis_types = config.analysisTypes; + if (config.enableVideo !== undefined) message.enable_video = config.enableVideo; + if (config.frameIntervalSeconds !== undefined) message.frame_interval_seconds = config.frameIntervalSeconds; + + const context = config.context; + if (context || withDefaultContext) { + message.context = { + ...(context?.language !== undefined && { language: context.language }), + ...(context?.ageGroup !== undefined && { age_group: context.ageGroup }), + ...(context?.childAge !== undefined && { child_age: context.childAge }), + platform: resolvePlatform(context?.platform), + }; + } + return message; +} /** * Creates a voice streaming session over WebSocket. @@ -54,20 +83,6 @@ export function createVoiceStream( }, }); - ws.on('open', () => { - active = true; - - // Send initial config if provided - if (config) { - const configMsg = { - type: 'config', - interval_seconds: config.intervalSeconds, - analysis_types: config.analysisTypes, - context: config.context, - }; - ws!.send(JSON.stringify(configMsg)); - } - }); ws.on('message', (data: Buffer | ArrayBuffer | Buffer[]) => { try { @@ -76,7 +91,13 @@ export function createVoiceStream( switch (event.type) { case 'ready': + // The API reads messages only once the session + // is ready, so the initial config goes now. sessionId = event.session_id; + active = true; + if (config) { + ws!.send(JSON.stringify(buildConfigMessage(config, true))); + } handlers?.onReady?.(event); resolve(); break; @@ -86,6 +107,9 @@ export function createVoiceStream( case 'alert': handlers?.onAlert?.(event); break; + case 'frame_analysis': + handlers?.onFrameAnalysis?.(event); + break; case 'session_summary': handlers?.onSessionSummary?.(event); summaryResolve?.(event); @@ -134,17 +158,21 @@ export function createVoiceStream( ws.send(data); }, + sendVideoFrame(data: Buffer | Uint8Array): void { + if (!ws || !active) { + throw new Error('Voice stream is not connected'); + } + const frame = new Uint8Array(data.length + 1); + frame[0] = VIDEO_FRAME_PREFIX; + frame.set(data, 1); + ws.send(frame); + }, + updateConfig(newConfig: VoiceStreamConfig): void { if (!ws || !active) { throw new Error('Voice stream is not connected'); } - const configMsg = { - type: 'config', - interval_seconds: newConfig.intervalSeconds, - analysis_types: newConfig.analysisTypes, - context: newConfig.context, - }; - ws.send(JSON.stringify(configMsg)); + ws.send(JSON.stringify(buildConfigMessage(newConfig, false))); }, async end(): Promise { diff --git a/tests/imageMatchAndClassification.test.ts b/tests/imageMatchAndClassification.test.ts new file mode 100644 index 0000000..e45424a --- /dev/null +++ b/tests/imageMatchAndClassification.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Tuteliq } from '../src/client.js'; +import { ValidationError, TuteliqError } from '../src/errors.js'; + +function mockFetchResponse(data: unknown, options: { ok?: boolean; status?: number } = {}) { + const { ok = true, status = 200 } = options; + return { + ok, + status, + json: async () => data, + headers: { get: () => null }, + } as Response; +} + +function bodySentIn(fetchSpy: ReturnType): Record { + return JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string); +} + +describe('verifyImageMatch', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const response = { + schema_version: '1.0', + checks_run: ['face_match', 'age'], + face_match: { matched: true, similarity: 0.91 }, + age: { estimated_age: 30, confidence: 0.98 }, + credits_used: 3, + processing_ms: 420, + external_id: 'ext-1', + }; + + it('posts JSON to /api/v1/verify-image-match with images base64-encoded', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(response)); + const image = Buffer.from([1, 2, 3, 4]); + const reference = new Uint8Array([5, 6, 7]); + + const result = await client.verifyImageMatch({ + image, + referenceImage: reference, + checks: ['face_match', 'age'], + external_id: 'ext-1', + customer_id: 'cust-1', + metadata: { source: 'test' }, + incident_moderation_enabled: false, + }); + + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.tuteliq.ai/api/v1/verify-image-match'); + expect((fetchSpy.mock.calls[0][1] as RequestInit).method).toBe('POST'); + expect(bodySentIn(fetchSpy)).toEqual({ + image: Buffer.from([1, 2, 3, 4]).toString('base64'), + reference_image: Buffer.from([5, 6, 7]).toString('base64'), + checks: ['face_match', 'age'], + external_id: 'ext-1', + customer_id: 'cust-1', + metadata: { source: 'test' }, + incident_moderation_enabled: false, + }); + expect(result).toEqual(response); + }); + + it('passes a base64 string through unchanged and omits unset fields', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(response)); + await client.verifyImageMatch({ image: 'AQIDBA==' }); + expect(bodySentIn(fetchSpy)).toEqual({ image: 'AQIDBA==' }); + }); + + it('rejects a missing image before calling the API', async () => { + const fetchSpy = vi.spyOn(global, 'fetch'); + await expect(client.verifyImageMatch({ image: '' })).rejects.toThrow(ValidationError); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('getSafetyClassification', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const classification = { + date: '2026-11-07', + score: 88, + band: 'leading', + insufficient_data: false, + reason: null, + period: 'all_time', + components: [{ key: 'reviewCoverage', label: 'Review coverage', points: 30, max: 30, tip: null }], + evidence: { + incident_count: 120, + reviewed_count: 118, + median_review_minutes: 14, + oldest_open_days: 0, + modalities_live: ['text'], + applicable_modalities: ['text'], + connected_platforms: 1, + calculated_at: '2026-11-07T02:15:00.000Z', + }, + calculated_at: '2026-11-07T02:15:00.000Z', + updated_at: '2026-11-07T02:15:01.000Z', + disclaimer: 'Operational classification, not a certification.', + }; + + it('GETs /api/v1/safety-classification and returns the stored classification', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(classification)); + const result = await client.getSafetyClassification(); + + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.tuteliq.ai/api/v1/safety-classification'); + expect((fetchSpy.mock.calls[0][1] as RequestInit).method).toBe('GET'); + expect(result).toEqual({ exists: true, classification }); + }); + + it('keeps a null score as null when data is insufficient', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({ + ...classification, score: null, band: null, insufficient_data: true, reason: 'Not enough incidents yet.', + })); + const result = await client.getSafetyClassification(); + + expect(result.exists).toBe(true); + if (result.exists) { + expect(result.classification.score).toBeNull(); + expect(result.classification.insufficient_data).toBe(true); + } + }); + + it('answers exists: false on 404, never a zero score', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse( + { error: 'no_classification', message: 'No safety classification exists for this account yet.' }, + { ok: false, status: 404 }, + )); + const result = await client.getSafetyClassification(); + + expect(result).toEqual({ exists: false }); + }); + + it('still throws on other errors', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse( + { error: { code: 'AUTH_1001', message: 'Invalid API key' } }, + { ok: false, status: 401 }, + )); + await expect(client.getSafetyClassification()).rejects.toThrow(TuteliqError); + }); +}); diff --git a/tests/mediaUploads.test.ts b/tests/mediaUploads.test.ts new file mode 100644 index 0000000..1757c80 --- /dev/null +++ b/tests/mediaUploads.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Tuteliq } from '../src/client.js'; + +// --------------------------------------------------------------------------- +// Form fields on the multipart media endpoints. Asserted on the FormData sent +// to fetch, since these routes take multipart/form-data, not a JSON body. +// --------------------------------------------------------------------------- + +function mockFetchResponse(data: unknown) { + return { + ok: true, + status: 200, + json: async () => data, + headers: { get: () => null }, + } as Response; +} + +function formSentIn(fetchSpy: ReturnType): FormData { + return fetchSpy.mock.calls[0][1]?.body as FormData; +} + +const file = Buffer.from('fake bytes'); + +describe('media upload form fields', () => { + let client: Tuteliq; + let fetchSpy: ReturnType; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockFetchResponse({})); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('incident_moderation_enabled', () => { + const cases: Array<[string, (value: boolean) => Promise]> = [ + ['analyzeVoice', (v) => client.analyzeVoice({ file, filename: 'a.mp3', incident_moderation_enabled: v })], + ['analyzeImage', (v) => client.analyzeImage({ file, filename: 'a.png', incident_moderation_enabled: v })], + ['analyzeVideo', (v) => client.analyzeVideo({ file, filename: 'a.mp4', incident_moderation_enabled: v })], + ['analyzeDocument', (v) => client.analyzeDocument({ file, filename: 'a.pdf', incident_moderation_enabled: v })], + ]; + + for (const [name, call] of cases) { + it(`${name}: forwards an explicit false`, async () => { + await call(false); + expect(formSentIn(fetchSpy).get('incident_moderation_enabled')).toBe('false'); + }); + + it(`${name}: forwards true`, async () => { + await call(true); + expect(formSentIn(fetchSpy).get('incident_moderation_enabled')).toBe('true'); + }); + } + + it('sends nothing when not set (the account setting applies)', async () => { + await client.analyzeImage({ file, filename: 'a.png' }); + expect(formSentIn(fetchSpy).get('incident_moderation_enabled')).toBeNull(); + }); + }); + + describe('flagRiskTerms on image and video', () => { + it('analyzeImage: sends nothing when not set', async () => { + await client.analyzeImage({ file, filename: 'a.png' }); + expect(formSentIn(fetchSpy).get('flag_risk_terms')).toBeNull(); + }); + + it('analyzeImage: forwards true and an explicit false', async () => { + await client.analyzeImage({ file, filename: 'a.png', flagRiskTerms: true }); + expect(formSentIn(fetchSpy).get('flag_risk_terms')).toBe('true'); + fetchSpy.mockClear(); + await client.analyzeImage({ file, filename: 'a.png', flagRiskTerms: false }); + expect(formSentIn(fetchSpy).get('flag_risk_terms')).toBe('false'); + }); + + it('analyzeVideo: forwards true and an explicit false', async () => { + await client.analyzeVideo({ file, filename: 'a.mp4', flagRiskTerms: true }); + expect(formSentIn(fetchSpy).get('flag_risk_terms')).toBe('true'); + fetchSpy.mockClear(); + await client.analyzeVideo({ file, filename: 'a.mp4', flagRiskTerms: false }); + expect(formSentIn(fetchSpy).get('flag_risk_terms')).toBe('false'); + }); + + it('analyzeImage: passes the risk_terms result through', async () => { + const riskTerms = { detected: true, matches: [{ term: 'example', category: 'drug' }] }; + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ risk_terms: riskTerms })); + const result = await client.analyzeImage({ file, filename: 'a.png', flagRiskTerms: true }); + expect(result.risk_terms).toEqual(riskTerms); + }); + }); + + describe('analyzeVideo options', () => { + it('forwards analysisType and maxFrames', async () => { + await client.analyzeVideo({ file, filename: 'a.mp4', analysisType: 'unsafe', maxFrames: 15 }); + const form = formSentIn(fetchSpy); + expect(form.get('analysis_type')).toBe('unsafe'); + expect(form.get('max_frames')).toBe('15'); + }); + + it('sends neither when not set', async () => { + await client.analyzeVideo({ file, filename: 'a.mp4' }); + const form = formSentIn(fetchSpy); + expect(form.get('analysis_type')).toBeNull(); + expect(form.get('max_frames')).toBeNull(); + }); + }); + + describe('analyzeDocument', () => { + it('forwards splitBySender for a screenshot', async () => { + await client.analyzeDocument({ file, filename: 'chat.png', splitBySender: true }); + const form = formSentIn(fetchSpy); + expect(form.get('split_by_sender')).toBe('true'); + expect((form.get('file') as Blob).type).toBe('image/png'); + }); + + it('sends no split_by_sender when not set', async () => { + await client.analyzeDocument({ file, filename: 'report.pdf' }); + expect(formSentIn(fetchSpy).get('split_by_sender')).toBeNull(); + }); + }); + + describe('fileId on synthetic media', () => { + it('detectSyntheticImage forwards file_id', async () => { + await client.detectSyntheticImage({ file, filename: 'a.jpg', fileId: 'img-1' }); + expect(formSentIn(fetchSpy).get('file_id')).toBe('img-1'); + }); + + it('detectSyntheticAudio forwards file_id', async () => { + await client.detectSyntheticAudio({ file, filename: 'a.mp3', fileId: 'aud-1' }); + expect(formSentIn(fetchSpy).get('file_id')).toBe('aud-1'); + }); + + it('detectSyntheticVideo forwards file_id', async () => { + await client.detectSyntheticVideo({ file, filename: 'a.mp4', fileId: 'vid-1' }); + expect(formSentIn(fetchSpy).get('file_id')).toBe('vid-1'); + }); + }); +}); diff --git a/tests/types/media.test-d.ts b/tests/types/media.test-d.ts new file mode 100644 index 0000000..e89e405 --- /dev/null +++ b/tests/types/media.test-d.ts @@ -0,0 +1,136 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import type { + AnalyzeDocumentInput, + AnalyzeImageInput, + AnalyzeVideoInput, + DocumentAnalysisResult, + ImageAnalysisResult, + VideoAnalysisResult, + VoiceAnalysisResult, +} from '../../src/types/media.js'; +import type { + AudioStats, + DetectSyntheticAudioInput, + DetectSyntheticImageInput, + DetectSyntheticVideoInput, + LipSyncResult, + SyntheticAudioResult, + SyntheticImageResult, + SyntheticVideoResult, + SyntheticVisionResult, + TemporalConsistency, +} from '../../src/types/synthetic.js'; +import type { + VoiceAlertEvent, + VoiceFrameAnalysisEvent, + VoiceSessionSummaryEvent, + VoiceStreamConfig, + VoiceStreamEvent, + VoiceStreamHandlers, + VoiceStreamSession, +} from '../../src/types/voice-stream.js'; + +// Wire shapes, pinned against what each endpoint actually returns. + +describe('media inputs', () => { + test('image and video take flagRiskTerms', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test('video takes analysisType and maxFrames', () => { + expectTypeOf().toEqualTypeOf<'unsafe' | 'all' | undefined>(); + expectTypeOf().toEqualTypeOf(); + }); + + test('document takes splitBySender', () => { + expectTypeOf().toEqualTypeOf(); + }); + + test('synthetic media inputs take fileId', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe('media results', () => { + test('voice reports its language', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<'stable' | 'beta' | undefined>(); + }); + + test('image and video carry risk_terms', () => { + type RiskTerms = { detected: boolean; matches: Array<{ term: string; category: 'drug' | 'violence' }> } | null | undefined; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test('document always returns file_id', () => { + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe('synthetic results', () => { + test('image returns semantic_analysis, not vision', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test('image returns the screenshot and deep image summaries and echoes', () => { + expectTypeOf().toHaveProperty('screenshot_detection'); + expectTypeOf().toHaveProperty('deep_image_analysis'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf | undefined>(); + }); + + test('audio stats use the field names the API sends', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test('audio transcription may come without segments', () => { + type Transcription = NonNullable; + expectTypeOf().toEqualTypeOf | undefined>(); + expectTypeOf().toHaveProperty('audio_forensics'); + expectTypeOf().toEqualTypeOf(); + }); + + test('video temporal consistency and lip sync match the wire', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf(); + }); + + test('video returns drift_signature, language_status, forensic and audio summaries', () => { + expectTypeOf().toEqualTypeOf<'sawtooth' | 'sinusoidal' | 'stable' | undefined>(); + expectTypeOf().toEqualTypeOf<'stable' | 'beta' | undefined>(); + expectTypeOf().toHaveProperty('forensic_signals'); + expectTypeOf().toHaveProperty('audio_forensics'); + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe('voice stream', () => { + test('config takes video options, childAge and the all analysis type', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf['childAge']>().toEqualTypeOf(); + expectTypeOf<'all'>().toMatchTypeOf[number]>(); + }); + + test('frame analysis is a stream event with its own handler', () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toEqualTypeOf<((event: VoiceFrameAnalysisEvent) => void) | undefined>(); + expectTypeOf().toEqualTypeOf<(data: Buffer | Uint8Array) => void>(); + }); + + test('alerts and summaries carry the visual category and frame count', () => { + expectTypeOf().toEqualTypeOf<'bullying' | 'unsafe' | 'grooming' | 'emotions' | 'visual'>(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<'low' | 'medium' | 'high' | 'critical'>(); + }); +}); diff --git a/tests/voiceStream.test.ts b/tests/voiceStream.test.ts new file mode 100644 index 0000000..a7bc4b4 --- /dev/null +++ b/tests/voiceStream.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; +import type { AddressInfo } from 'node:net'; +import type { IncomingMessage } from 'node:http'; +import { WebSocketServer, type WebSocket as ServerSocket, type RawData } from 'ws'; +import { createVoiceStream } from '../src/voice-stream.js'; +import type { VoiceStreamSession } from '../src/types/voice-stream.js'; + +// --------------------------------------------------------------------------- +// The voice stream is tested against a local WebSocketServer. `ws` is mocked +// with a subclass that records the URL the SDK asked for and then dials the +// local server on the same path, so no test ever reaches the network. +// +// The local server behaves like the API: it authenticates first, sends +// `ready`, and only then listens for messages. Anything the client sends +// before `ready` is recorded as dropped, because the API never sees it. +// --------------------------------------------------------------------------- + +const requestedUrls: string[] = []; +let localPort = 0; + +vi.mock('ws', async (importOriginal) => { + const real = await importOriginal(); + class RedirectingWebSocket extends real.WebSocket { + constructor(address: string, options?: import('ws').ClientOptions) { + requestedUrls.push(address); + const target = new URL(address); + super(`ws://127.0.0.1:${localPort}${target.pathname}${target.search}`, options); + } + } + return { ...real, default: RedirectingWebSocket, WebSocket: RedirectingWebSocket }; +}); + +interface Received { + path: string | undefined; + authorization: string | undefined; + beforeReady: RawData[]; + afterReady: Array<{ data: RawData; isBinary: boolean }>; + socket: ServerSocket; +} + +let server: WebSocketServer; +let connections: Received[] = []; + +beforeAll(async () => { + server = new WebSocketServer({ port: 0, host: '127.0.0.1' }); + await new Promise((resolve) => server.once('listening', () => resolve())); + localPort = (server.address() as AddressInfo).port; + + server.on('connection', (socket: ServerSocket, request: IncomingMessage) => { + const received: Received = { + path: request.url, + authorization: request.headers.authorization, + beforeReady: [], + afterReady: [], + socket, + }; + connections.push(received); + + const early = (data: RawData) => received.beforeReady.push(data); + socket.on('message', early); + + // Simulated async authentication, then ready. + setTimeout(() => { + socket.off('message', early); + socket.on('message', (data: RawData, isBinary: boolean) => { + received.afterReady.push({ data, isBinary }); + if (!isBinary) { + const msg = JSON.parse(data.toString()); + if (msg.type === 'config') { + socket.send(JSON.stringify({ type: 'config_updated', config: {} })); + } + } + }); + socket.send(JSON.stringify({ + type: 'ready', + session_id: 'vs_test', + config: { interval_seconds: 10, analysis_types: ['all'], enable_video: false, frame_interval_seconds: 5 }, + })); + }, 30); + }); +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +let session: VoiceStreamSession | null = null; + +afterEach(() => { + session?.close(); + session = null; + connections = []; + requestedUrls.length = 0; +}); + +function waitFor(condition: () => boolean, timeoutMs = 1000): Promise { + return new Promise((resolve, reject) => { + const started = Date.now(); + const tick = () => { + if (condition()) return resolve(); + if (Date.now() - started > timeoutMs) return reject(new Error('timed out waiting for condition')); + setTimeout(tick, 5); + }; + tick(); + }); +} + +function jsonMessages(received: Received): Array> { + return received.afterReady + .filter((m) => !m.isBinary) + .map((m) => JSON.parse(m.data.toString())); +} + +describe('voice stream', () => { + it('connects to the path the API serves, with Bearer auth', async () => { + const onReady = vi.fn(); + session = createVoiceStream('test-api-key', undefined, { onReady }); + await waitFor(() => onReady.mock.calls.length === 1); + + expect(requestedUrls).toEqual(['wss://api.tuteliq.ai/api/v1/safety/voice/stream']); + expect(connections[0].path).toBe('/api/v1/safety/voice/stream'); + expect(connections[0].authorization).toBe('Bearer test-api-key'); + }); + + it('sends the initial config after ready, so the API does not drop it', async () => { + const onConfigUpdated = vi.fn(); + session = createVoiceStream('test-api-key', { intervalSeconds: 15 }, { onConfigUpdated }); + await waitFor(() => onConfigUpdated.mock.calls.length === 1); + + expect(connections[0].beforeReady).toHaveLength(0); + expect(jsonMessages(connections[0])[0]).toMatchObject({ type: 'config', interval_seconds: 15 }); + }); + + it('sends context in the wire format the API reads', async () => { + const onConfigUpdated = vi.fn(); + session = createVoiceStream('test-api-key', { + analysisTypes: ['all'], + enableVideo: true, + frameIntervalSeconds: 6, + context: { language: 'en', ageGroup: '10-12', childAge: 11, platform: 'MyApp' }, + }, { onConfigUpdated }); + await waitFor(() => onConfigUpdated.mock.calls.length === 1); + + const config = jsonMessages(connections[0])[0]; + expect(config).toEqual({ + type: 'config', + analysis_types: ['all'], + enable_video: true, + frame_interval_seconds: 6, + context: { language: 'en', age_group: '10-12', child_age: 11, platform: 'MyApp - Node SDK' }, + }); + }); + + it('attributes the platform even when no context is given', async () => { + const onConfigUpdated = vi.fn(); + session = createVoiceStream('test-api-key', { intervalSeconds: 10 }, { onConfigUpdated }); + await waitFor(() => onConfigUpdated.mock.calls.length === 1); + + expect(jsonMessages(connections[0])[0].context).toEqual({ platform: 'Node SDK' }); + }); + + it('updateConfig maps context the same way and leaves context out when not given', async () => { + const onReady = vi.fn(); + const onConfigUpdated = vi.fn(); + session = createVoiceStream('test-api-key', undefined, { onReady, onConfigUpdated }); + await waitFor(() => onReady.mock.calls.length === 1); + + session.updateConfig({ context: { ageGroup: '13-15', platform: 'MyApp' } }); + session.updateConfig({ intervalSeconds: 20 }); + await waitFor(() => onConfigUpdated.mock.calls.length === 2); + + const [first, second] = jsonMessages(connections[0]); + expect(first.context).toEqual({ age_group: '13-15', platform: 'MyApp - Node SDK' }); + expect(second).toEqual({ type: 'config', interval_seconds: 20 }); + }); + + it('sends video frames as binary prefixed with 0x01, and audio untagged', async () => { + const onReady = vi.fn(); + session = createVoiceStream('test-api-key', undefined, { onReady }); + await waitFor(() => onReady.mock.calls.length === 1); + + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); + session.sendVideoFrame(jpeg); + session.sendAudio(Buffer.from([0x10, 0x20])); + await waitFor(() => connections[0].afterReady.length === 2); + + const [frame, audio] = connections[0].afterReady; + expect(frame.isBinary).toBe(true); + expect([...(frame.data as Buffer)]).toEqual([0x01, 0xff, 0xd8, 0xff, 0xe0]); + expect([...(audio.data as Buffer)]).toEqual([0x10, 0x20]); + }); + + it('delivers frame_analysis events to onFrameAnalysis', async () => { + const onReady = vi.fn(); + const onFrameAnalysis = vi.fn(); + session = createVoiceStream('test-api-key', undefined, { onReady, onFrameAnalysis }); + await waitFor(() => onReady.mock.calls.length === 1); + + const event = { + type: 'frame_analysis', + session_id: 'vs_test', + frame_index: 0, + timestamp: '2026-11-08T00:00:00.000Z', + vision: { + extracted_text: '', + visual_categories: [], + visual_severity: 'none', + visual_confidence: 0.9, + visual_description: '', + contains_text: false, + contains_faces: false, + }, + risk_score: 0, + severity: 'none', + }; + connections[0].socket.send(JSON.stringify(event)); + await waitFor(() => onFrameAnalysis.mock.calls.length === 1); + + expect(onFrameAnalysis).toHaveBeenCalledWith(event); + }); +}); diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json new file mode 100644 index 0000000..868d97a --- /dev/null +++ b/tsconfig.typecheck.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["src/**/*", "tests/types/**/*.test-d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts index 21b1666..a3f2550 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,5 +4,12 @@ export default defineConfig({ test: { include: ['tests/**/*.test.ts'], exclude: ['node_modules', 'dist'], + // Type-level tests: a result type that names a field the API does not + // send passes every runtime test, so wire shapes are pinned with tsc. + typecheck: { + enabled: true, + include: ['tests/types/**/*.test-d.ts'], + tsconfig: './tsconfig.typecheck.json', + }, }, }); From ebb8b67550f01354f8013c5f9a8197923735dec9 Mon Sep 17 00:00:00 2001 From: safenestdev Date: Sun, 8 Nov 2026 08:49:17 +0100 Subject: [PATCH 2/2] fix(media): synthetic media result types follow the published response shape Synthetic image, audio and video results now list only the fields the API publishes: screenshot_detection carries is_screenshot and confidence, AudioStats carries the seven published measurements, and the forensic summary carries signal_count, sources and combined_confidence_boost. action_detail is deprecated on these results, as it is not part of their response. Type-level tests pin the exact key sets. --- src/index.ts | 2 -- src/types/synthetic.ts | 61 +++++-------------------------------- tests/types/media.test-d.ts | 25 ++++++++++----- 3 files changed, 25 insertions(+), 63 deletions(-) diff --git a/src/index.ts b/src/index.ts index c1eafa3..aabc2a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -234,10 +234,8 @@ export type { DetectSyntheticAudioInput, SyntheticAudioResult, AudioStats, - AudioForensics, SyntheticTranscription, ScreenshotDetection, - DeepImageAnalysis, DetectSyntheticVideoInput, SyntheticVideoResult, TemporalConsistency, diff --git a/src/types/synthetic.ts b/src/types/synthetic.ts index 65461c2..0c84b86 100644 --- a/src/types/synthetic.ts +++ b/src/types/synthetic.ts @@ -178,23 +178,10 @@ export interface KnownSyntheticMatch { /** The image looks like a screen capture rather than a camera photo. */ export interface ScreenshotDetection { - is_screenshot: true; + /** Whether the image looks like a screen capture */ + is_screenshot: boolean; /** Confidence (0.0-1.0) */ confidence: number; - /** Signals behind the assessment */ - signals: string[]; -} - -/** Learned whole-image classifier result. */ -export interface DeepImageAnalysis { - /** Whether the classifier judged the image synthetic */ - is_synthetic: boolean; - /** Probability the image is synthetic (0.0-1.0) */ - synthetic_probability: number; - /** Confidence (0.0-1.0) */ - confidence: number; - /** Time taken in milliseconds */ - processing_ms: number; } export interface SyntheticImageResult { @@ -218,13 +205,10 @@ export interface SyntheticImageResult { evidence?: DetectionEvidence[]; /** * Recommended action, as a stable enum ordered weakest to strongest. - * Safe to switch on; human guidance is in `action_detail`. + * Safe to switch on. */ recommended_action: RecommendedAction; - /** - * Optional human-readable expansion of `recommended_action`, for a - * moderator UI. Free text: do not branch on it. - */ + /** @deprecated Not part of this endpoint's response; never present. */ action_detail?: string; /** Human-readable rationale */ rationale: string; @@ -248,8 +232,6 @@ export interface SyntheticImageResult { known_synthetic_match?: KnownSyntheticMatch; /** Present only when the image looks like a screen capture rather than a photo */ screenshot_detection?: ScreenshotDetection; - /** Learned whole-image classifier result (present when that analysis ran) */ - deep_image_analysis?: DeepImageAnalysis; /** Processing time in milliseconds */ processing_time_ms?: number; /** Language code */ @@ -315,12 +297,6 @@ export interface AudioStats { flat_factor: number; /** DC offset */ dc_offset: number; - /** Smoothness of spectral change over time */ - mfcc_delta_smoothness?: number; - /** Regularity of pitch */ - f0_regularity?: number; - /** Formant bandwidth */ - formant_bandwidth?: number; } /** @@ -332,17 +308,6 @@ export interface SyntheticTranscription extends Omit; - /** Signals that point to synthetic audio */ - signals: string[]; - /** Contribution to the overall confidence */ - confidence_boost: number; - /** Extra detail, when returned; shape not guaranteed */ - details?: unknown; -} export interface SyntheticAudioResult { /** Endpoint name */ @@ -363,13 +328,10 @@ export interface SyntheticAudioResult { categories: DetectionCategory[]; /** * Recommended action, as a stable enum ordered weakest to strongest. - * Safe to switch on; human guidance is in `action_detail`. + * Safe to switch on. */ recommended_action: RecommendedAction; - /** - * Optional human-readable expansion of `recommended_action`, for a - * moderator UI. Free text: do not branch on it. - */ + /** @deprecated Not part of this endpoint's response; never present. */ action_detail?: string; /** Human-readable rationale */ rationale: string; @@ -385,8 +347,6 @@ export interface SyntheticAudioResult { audio_stats?: AudioStats; /** Spectral analysis signals (e.g., low dynamic range, frequency uniformity) */ spectral_signals?: string[]; - /** Signal-processing forensics on the waveform */ - audio_forensics?: AudioForensics; /** Processing time in milliseconds */ processing_time_ms?: number; /** Language code */ @@ -501,13 +461,10 @@ export interface SyntheticVideoResult { categories: DetectionCategory[]; /** * Recommended action, as a stable enum ordered weakest to strongest. - * Safe to switch on; human guidance is in `action_detail`. + * Safe to switch on. */ recommended_action: RecommendedAction; - /** - * Optional human-readable expansion of `recommended_action`, for a - * moderator UI. Free text: do not branch on it. - */ + /** @deprecated Not part of this endpoint's response; never present. */ action_detail?: string; /** Human-readable rationale */ rationale: string; @@ -534,8 +491,6 @@ export interface SyntheticVideoResult { audio_stats?: AudioStats; /** Spectral analysis signals */ spectral_signals?: string[]; - /** Signal-processing forensics on the audio track (present when it found signals) */ - audio_forensics?: Omit; /** Multi-signal summary (present only when there are signals) */ forensic_signals?: ForensicSignals; /** Evidence excerpts */ diff --git a/tests/types/media.test-d.ts b/tests/types/media.test-d.ts index e89e405..6b1cc6d 100644 --- a/tests/types/media.test-d.ts +++ b/tests/types/media.test-d.ts @@ -11,6 +11,7 @@ import type { import type { AudioStats, DetectSyntheticAudioInput, + ForensicSignals, DetectSyntheticImageInput, DetectSyntheticVideoInput, LipSyncResult, @@ -77,23 +78,31 @@ describe('synthetic results', () => { expectTypeOf().toEqualTypeOf(); }); - test('image returns the screenshot and deep image summaries and echoes', () => { - expectTypeOf().toHaveProperty('screenshot_detection'); - expectTypeOf().toHaveProperty('deep_image_analysis'); + test('image returns the public screenshot summary and echoes, nothing more', () => { + type Screenshot = NonNullable; + expectTypeOf().toEqualTypeOf<'is_screenshot' | 'confidence'>(); + expectTypeOf<'deep_image_analysis' extends keyof SyntheticImageResult ? true : false>().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf | undefined>(); }); - test('audio stats use the field names the API sends', () => { + test('audio stats hold exactly the public keys, plus the deprecated rms_mean', () => { + expectTypeOf().toEqualTypeOf< + 'rms_level' | 'rms_peak' | 'dc_offset' | 'dynamic_range' | 'silence_ratio' | 'peak_count' | 'flat_factor' | 'rms_mean' + >(); expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); + test('forensic summary holds only the public fields', () => { + expectTypeOf().toEqualTypeOf<'signal_count' | 'sources' | 'combined_confidence_boost'>(); + expectTypeOf().toEqualTypeOf<'name' | 'signal_count' | 'confidence_boost'>(); + }); + test('audio transcription may come without segments', () => { type Transcription = NonNullable; expectTypeOf().toEqualTypeOf | undefined>(); - expectTypeOf().toHaveProperty('audio_forensics'); + expectTypeOf<'audio_forensics' extends keyof SyntheticAudioResult ? true : false>().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); @@ -105,11 +114,11 @@ describe('synthetic results', () => { expectTypeOf().toEqualTypeOf(); }); - test('video returns drift_signature, language_status, forensic and audio summaries', () => { + test('video returns drift_signature, language_status and the forensic summary', () => { expectTypeOf().toEqualTypeOf<'sawtooth' | 'sinusoidal' | 'stable' | undefined>(); expectTypeOf().toEqualTypeOf<'stable' | 'beta' | undefined>(); expectTypeOf().toHaveProperty('forensic_signals'); - expectTypeOf().toHaveProperty('audio_forensics'); + expectTypeOf<'audio_forensics' extends keyof SyntheticVideoResult ? true : false>().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); });