diff --git a/apps/api/src/modules/shared/infrastructure/event-bus/__tests__/contracts-phase2.spec.ts b/apps/api/src/modules/shared/infrastructure/event-bus/__tests__/contracts-phase2.spec.ts new file mode 100644 index 0000000..68d456a --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/event-bus/__tests__/contracts-phase2.spec.ts @@ -0,0 +1,146 @@ +import { + EVENT_ENVELOPE_SCHEMA_VERSION, + isKnownEventType, + isKnownProducer, + KNOWN_EVENT_TYPES, + KNOWN_PRODUCERS, + uuidv7, + validateEnvelope, + type EventEnvelope, + type ListingAvmScoredPayload, + type ListingModerationScoredPayload, + type ListingNlpEnrichedPayload, + type ListingSubmittedPayload, +} from '@goodgo/contracts-events'; +import { describe, it, expect } from 'vitest'; + +/** + * RFC-004 Phase 2 (GOO-174) — AI offload contracts. + * + * These tests exercise the new `listing.*` event types and producer + * identifiers added on top of the Phase 0 envelope contract. + */ +describe('@goodgo/contracts-events — RFC-004 Phase 2 (GOO-174)', () => { + describe('KNOWN_EVENT_TYPES', () => { + it.each([ + 'listing.submitted', + 'listing.moderation_scored', + 'listing.avm_scored', + 'listing.nlp_enriched', + ])('includes %s', (eventType) => { + expect(KNOWN_EVENT_TYPES).toContain(eventType); + expect(isKnownEventType(eventType)).toBe(true); + }); + + it('does not contain duplicates', () => { + const set = new Set(KNOWN_EVENT_TYPES); + expect(set.size).toBe(KNOWN_EVENT_TYPES.length); + }); + }); + + describe('KNOWN_PRODUCERS', () => { + it.each(['api.listings', 'ai.moderation', 'ai.avm', 'ai.nlp'])( + 'includes %s', + (producer) => { + expect(KNOWN_PRODUCERS).toContain(producer); + expect(isKnownProducer(producer)).toBe(true); + }, + ); + + it('rejects unknown producers', () => { + expect(isKnownProducer('ai.unknown')).toBe(false); + expect(isKnownProducer('frontend')).toBe(false); + }); + }); + + describe('envelope round-trip with Phase 2 payloads', () => { + const baseEnvelope = ( + eventType: string, + producer: string, + payload: T, + ): EventEnvelope => ({ + schemaVersion: EVENT_ENVELOPE_SCHEMA_VERSION, + eventId: uuidv7(), + eventType, + occurredAt: '2026-04-24T07:00:00.000Z', + producer, + traceId: 'a'.repeat(32), + payload, + }); + + it('validates a listing.submitted envelope from api.listings', () => { + const payload: ListingSubmittedPayload = { + listingId: 'lst_01HZX', + ownerId: 'usr_01HZ', + propertyType: 'apartment', + priceVnd: '4500000000', + locationRef: { + districtCode: '760', + wardCode: '76001', + lat: 10.7769, + lng: 106.7009, + }, + descriptionHash: '0'.repeat(64), + submittedAt: '2026-04-24T07:00:00.000Z', + }; + const envelope = baseEnvelope( + 'listing.submitted', + 'api.listings', + payload, + ); + expect(validateEnvelope(envelope)).toEqual([]); + }); + + it('validates a listing.moderation_scored envelope from ai.moderation', () => { + const payload: ListingModerationScoredPayload = { + listingId: 'lst_01HZX', + decision: 'needs_review', + confidence: 0.62, + reasons: ['contains_phone_in_body'], + modelVersion: '2026.04.1', + scoredAt: '2026-04-24T07:00:05.000Z', + }; + const envelope = baseEnvelope( + 'listing.moderation_scored', + 'ai.moderation', + payload, + ); + expect(validateEnvelope(envelope)).toEqual([]); + }); + + it('validates a listing.avm_scored envelope from ai.avm', () => { + const payload: ListingAvmScoredPayload = { + listingId: 'lst_01HZX', + estimatedPriceVnd: '4400000000', + lowVnd: '4100000000', + highVnd: '4700000000', + confidence: 0.88, + modelVersion: 'avm-v2.1', + scoredAt: '2026-04-24T07:00:08.000Z', + }; + const envelope = baseEnvelope( + 'listing.avm_scored', + 'ai.avm', + payload, + ); + expect(validateEnvelope(envelope)).toEqual([]); + }); + + it('validates a listing.nlp_enriched envelope from ai.nlp', () => { + const payload: ListingNlpEnrichedPayload = { + listingId: 'lst_01HZX', + qualityScore: 0.74, + keywords: ['căn hộ', 'view sông', 'đầy đủ nội thất'], + detectedLanguage: 'vi', + modelVersion: 'nlp-vi-1.3', + scoredAt: '2026-04-24T07:00:11.000Z', + }; + const envelope = baseEnvelope( + 'listing.nlp_enriched', + 'ai.nlp', + payload, + ); + expect(validateEnvelope(envelope)).toEqual([]); + }); + }); +}); diff --git a/libs/contracts/events/package.json b/libs/contracts/events/package.json index 7a68abb..e6d305b 100644 --- a/libs/contracts/events/package.json +++ b/libs/contracts/events/package.json @@ -1,6 +1,6 @@ { "name": "@goodgo/contracts-events", - "version": "0.1.0", + "version": "0.2.0", "private": true, "main": "./src/index.ts", "types": "./src/index.ts", diff --git a/libs/contracts/events/schemas/listing.avm_scored.schema.json b/libs/contracts/events/schemas/listing.avm_scored.schema.json new file mode 100644 index 0000000..c764b37 --- /dev/null +++ b/libs/contracts/events/schemas/listing.avm_scored.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://goodgo.vn/schemas/events/listing.avm_scored.schema.json", + "title": "listing.avm_scored", + "description": "Emitted by the AI AVM worker with an automated valuation for a submitted listing. Consumed by the listings projection to populate price-guidance fields.", + "type": "object", + "additionalProperties": false, + "required": [ + "listingId", + "estimatedPriceVnd", + "lowVnd", + "highVnd", + "confidence", + "modelVersion", + "scoredAt" + ], + "properties": { + "listingId": { "type": "string", "minLength": 1 }, + "estimatedPriceVnd": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Point estimate in VND as stringified integer." + }, + "lowVnd": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Lower bound of the estimate range (typically p10)." + }, + "highVnd": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Upper bound of the estimate range (typically p90)." + }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, + "modelVersion": { "type": "string", "minLength": 1 }, + "scoredAt": { "type": "string", "format": "date-time" } + } +} diff --git a/libs/contracts/events/schemas/listing.moderation_scored.schema.json b/libs/contracts/events/schemas/listing.moderation_scored.schema.json new file mode 100644 index 0000000..af088b5 --- /dev/null +++ b/libs/contracts/events/schemas/listing.moderation_scored.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://goodgo.vn/schemas/events/listing.moderation_scored.schema.json", + "title": "listing.moderation_scored", + "description": "Emitted by the AI moderation worker after scoring a submitted listing. Consumed by the API listings projection to update moderation state.", + "type": "object", + "additionalProperties": false, + "required": [ + "listingId", + "decision", + "confidence", + "reasons", + "modelVersion", + "scoredAt" + ], + "properties": { + "listingId": { "type": "string", "minLength": 1 }, + "decision": { + "type": "string", + "enum": ["approve", "reject", "needs_review"] + }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, + "reasons": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Short machine-readable reason codes, e.g. scam_keywords, duplicate, banned_terms." + }, + "modelVersion": { + "type": "string", + "minLength": 1, + "description": "Semantic version of the moderation model that produced this decision." + }, + "scoredAt": { "type": "string", "format": "date-time" } + } +} diff --git a/libs/contracts/events/schemas/listing.nlp_enriched.schema.json b/libs/contracts/events/schemas/listing.nlp_enriched.schema.json new file mode 100644 index 0000000..708a7cd --- /dev/null +++ b/libs/contracts/events/schemas/listing.nlp_enriched.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://goodgo.vn/schemas/events/listing.nlp_enriched.schema.json", + "title": "listing.nlp_enriched", + "description": "Emitted by the AI NLP worker with quality/keyword enrichment for a submitted listing. Consumed by the listings projection to update search + quality-score fields.", + "type": "object", + "additionalProperties": false, + "required": [ + "listingId", + "qualityScore", + "keywords", + "detectedLanguage", + "modelVersion", + "scoredAt" + ], + "properties": { + "listingId": { "type": "string", "minLength": 1 }, + "qualityScore": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Normalized description-quality score. Higher = better." + }, + "keywords": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "maxItems": 50, + "description": "Extracted keywords / key phrases (Vietnamese or English)." + }, + "detectedLanguage": { + "type": "string", + "pattern": "^[a-z]{2}(-[A-Z]{2})?$", + "description": "IETF BCP-47 language tag, e.g. vi, en, vi-VN." + }, + "modelVersion": { "type": "string", "minLength": 1 }, + "scoredAt": { "type": "string", "format": "date-time" } + } +} diff --git a/libs/contracts/events/schemas/listing.submitted.schema.json b/libs/contracts/events/schemas/listing.submitted.schema.json new file mode 100644 index 0000000..0dfc135 --- /dev/null +++ b/libs/contracts/events/schemas/listing.submitted.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://goodgo.vn/schemas/events/listing.submitted.schema.json", + "title": "listing.submitted", + "description": "Emitted when a Listing is submitted by its owner. Fan-out consumed by AI workers (moderation, AVM, NLP) on the async backbone — see RFC-004 Phase 2 (GOO-174).", + "type": "object", + "additionalProperties": false, + "required": [ + "listingId", + "ownerId", + "propertyType", + "priceVnd", + "locationRef", + "descriptionHash", + "submittedAt" + ], + "properties": { + "listingId": { "type": "string", "minLength": 1 }, + "ownerId": { "type": "string", "minLength": 1 }, + "propertyType": { + "type": "string", + "description": "Domain property type enum (e.g. apartment, house, land, commercial, industrial)." + }, + "priceVnd": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Asking price in VND as stringified integer — avoids JS number precision loss on large values." + }, + "locationRef": { + "type": "object", + "additionalProperties": false, + "required": ["districtCode", "wardCode"], + "properties": { + "districtCode": { "type": "string", "minLength": 1 }, + "wardCode": { "type": "string", "minLength": 1 }, + "lat": { "type": "number", "minimum": -90, "maximum": 90 }, + "lng": { "type": "number", "minimum": -180, "maximum": 180 } + } + }, + "descriptionHash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "SHA-256 of the raw description text — lets AI workers detect duplicates without carrying the full body." + }, + "submittedAt": { "type": "string", "format": "date-time" } + } +} diff --git a/libs/contracts/events/src/event-types.ts b/libs/contracts/events/src/event-types.ts index ff03605..3694a3d 100644 --- a/libs/contracts/events/src/event-types.ts +++ b/libs/contracts/events/src/event-types.ts @@ -1,6 +1,10 @@ export const KNOWN_EVENT_TYPES = [ 'kyc.verified', 'listing.approved', + 'listing.avm_scored', + 'listing.moderation_scored', + 'listing.nlp_enriched', + 'listing.submitted', 'payment.completed', ] as const; @@ -9,3 +13,18 @@ export type KnownEventType = (typeof KNOWN_EVENT_TYPES)[number]; export function isKnownEventType(value: string): value is KnownEventType { return (KNOWN_EVENT_TYPES as readonly string[]).includes(value); } + +export const KNOWN_PRODUCERS = [ + 'api', + 'api.listings', + 'ai.avm', + 'ai.moderation', + 'ai.nlp', + 'ai-services', +] as const; + +export type KnownProducer = (typeof KNOWN_PRODUCERS)[number]; + +export function isKnownProducer(value: string): value is KnownProducer { + return (KNOWN_PRODUCERS as readonly string[]).includes(value); +} diff --git a/libs/contracts/events/src/index.ts b/libs/contracts/events/src/index.ts index cd525cc..a70ee2a 100644 --- a/libs/contracts/events/src/index.ts +++ b/libs/contracts/events/src/index.ts @@ -6,7 +6,14 @@ export { assertValidEnvelope, } from './envelope'; export { uuidv7, isUuidV7 } from './uuid-v7'; -export { KNOWN_EVENT_TYPES, type KnownEventType, isKnownEventType } from './event-types'; +export { + KNOWN_EVENT_TYPES, + type KnownEventType, + isKnownEventType, + KNOWN_PRODUCERS, + type KnownProducer, + isKnownProducer, +} from './event-types'; export interface PaymentCompletedPayload { paymentId: string; @@ -35,3 +42,62 @@ export interface KycVerifiedPayload { verifiedAt: string; documentRefs: string[]; } + +// ────────────────────────────────────────────────────────────────────────── +// RFC-004 Phase 2 — AI offload contracts (GOO-174) +// ────────────────────────────────────────────────────────────────────────── + +export interface ListingSubmittedPayload { + listingId: string; + ownerId: string; + propertyType: string; + /** VND amount as stringified integer (preserves precision over JS number range). */ + priceVnd: string; + locationRef: { + districtCode: string; + wardCode: string; + lat?: number; + lng?: number; + }; + /** SHA-256 of the raw description text (lowercase hex). */ + descriptionHash: string; + submittedAt: string; +} + +export type ListingModerationDecision = 'approve' | 'reject' | 'needs_review'; + +export interface ListingModerationScoredPayload { + listingId: string; + decision: ListingModerationDecision; + /** [0, 1]. */ + confidence: number; + /** Short machine-readable reason codes. */ + reasons: string[]; + modelVersion: string; + scoredAt: string; +} + +export interface ListingAvmScoredPayload { + listingId: string; + /** Point estimate in VND as stringified integer. */ + estimatedPriceVnd: string; + /** Lower bound (typically p10) in VND. */ + lowVnd: string; + /** Upper bound (typically p90) in VND. */ + highVnd: string; + /** [0, 1]. */ + confidence: number; + modelVersion: string; + scoredAt: string; +} + +export interface ListingNlpEnrichedPayload { + listingId: string; + /** [0, 1]. Higher = better description quality. */ + qualityScore: number; + keywords: string[]; + /** IETF BCP-47 tag, e.g. vi, en, vi-VN. */ + detectedLanguage: string; + modelVersion: string; + scoredAt: string; +}