From 1bd42a5c806a6f0cd3c903ba9e0de53dfa56cd8d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 06:53:51 +0000 Subject: [PATCH 1/4] fix(agents): word-boundary review topics and rating-3 sentiment cues Stop substring false positives (bar/ac) and lean mixed-star reviews using simple positive/negative word cues. Keep draft confidence modest. Co-authored-by: telivity-otaip --- .../review-response.models.spec.ts | 36 ++++++-- .../review-response/review-response.models.ts | 86 ++++++++++++++++--- 2 files changed, 106 insertions(+), 16 deletions(-) diff --git a/apps/api/src/modules/agent/review-response/review-response.models.spec.ts b/apps/api/src/modules/agent/review-response/review-response.models.spec.ts index b1aaa812..f7687fde 100644 --- a/apps/api/src/modules/agent/review-response/review-response.models.spec.ts +++ b/apps/api/src/modules/agent/review-response/review-response.models.spec.ts @@ -17,8 +17,21 @@ describe('classifySentiment', () => { expect(classifySentiment(5)).toBe('positive'); }); - it('classifies 3 as mixed', () => { + it('classifies 3 as mixed when cues are balanced or absent', () => { expect(classifySentiment(3)).toBe('mixed'); + expect(classifySentiment(3, 'Staff was great but the room was disappointing')).toBe('mixed'); + }); + + it('leans a rating-3 review negative when complaint cues dominate', () => { + expect( + classifySentiment(3, 'Dirty room, terrible smell, rude service, never again'), + ).toBe('negative'); + }); + + it('leans a rating-3 review positive when praise cues dominate', () => { + expect( + classifySentiment(3, 'Amazing staff, wonderful location, excellent breakfast, loved it'), + ).toBe('positive'); }); it('classifies 1-2 as negative', () => { @@ -73,6 +86,18 @@ describe('extractTopics', () => { const topics = extractTopics('The bed was extremely comfortable and the bathroom was modern'); expect(topics).toContain('room_quality'); }); + + it('does not treat substring false positives as topics', () => { + // "bargain" must not match amenities "bar"; "vacations" must not match "ac" + const topics = extractTopics('Found a bargain on our vacations package online'); + expect(topics).not.toContain('amenities'); + expect(topics).not.toContain('room_quality'); + }); + + it('still matches AC as a whole word for room quality', () => { + expect(extractTopics('The AC was broken all night')).toContain('room_quality'); + expect(extractTopics('The bar was closed')).toContain('amenities'); + }); }); // --------------------------------------------------------------------------- @@ -180,7 +205,7 @@ describe('generateResponseDraft', () => { expect(draft.keyPointsAddressed.length).toBe(0); }); - it('confidence increases with topic coverage', () => { + it('confidence increases with topic coverage but stays modest', () => { const noTopics = generateResponseDraft(5, 'Nice', 'Ivy', config); const withTopics = generateResponseDraft( 2, @@ -188,16 +213,17 @@ describe('generateResponseDraft', () => { 'Jay', config, ); - expect(withTopics.confidence).toBeGreaterThanOrEqual(0.70); + expect(withTopics.confidence).toBeGreaterThan(noTopics.confidence); + expect(withTopics.confidence).toBeLessThanOrEqual(0.85); }); - it('caps confidence at 0.95', () => { + it('caps keyword-based confidence at 0.85', () => { const draft = generateResponseDraft( 1, 'Dirty, noisy, rude staff, overpriced, terrible food, bad parking, slow wifi', 'Kay', config, ); - expect(draft.confidence).toBeLessThanOrEqual(0.95); + expect(draft.confidence).toBeLessThanOrEqual(0.85); }); }); diff --git a/apps/api/src/modules/agent/review-response/review-response.models.ts b/apps/api/src/modules/agent/review-response/review-response.models.ts index bfdc4257..c6d620a4 100644 --- a/apps/api/src/modules/agent/review-response/review-response.models.ts +++ b/apps/api/src/modules/agent/review-response/review-response.models.ts @@ -49,14 +49,73 @@ export interface ReviewResponseConfig { // Sentiment classification // --------------------------------------------------------------------------- -export function classifySentiment(rating: number): ReviewSentiment { +const POSITIVE_CUES = [ + 'great', + 'amazing', + 'wonderful', + 'excellent', + 'loved', + 'perfect', + 'friendly', + 'clean', + 'helpful', + 'comfortable', + 'recommend', +]; + +const NEGATIVE_CUES = [ + 'dirty', + 'terrible', + 'awful', + 'rude', + 'slow', + 'broken', + 'worst', + 'disappointed', + 'noisy', + 'smell', + 'overpriced', + 'never', + 'however', + 'but', +]; + +/** Escape a keyword for RegExp; spaces become flexible whitespace. */ +function keywordPattern(keyword: string): RegExp { + const escaped = keyword + .toLowerCase() + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/\s+/g, '\\s+'); + // Word-ish boundaries so "bar" ≠ "bargain" and "ac" ≠ "vacations". + return new RegExp(`(?= 4) return 'positive'; - if (rating === 3) return 'mixed'; - return 'negative'; + if (rating <= 2) return 'negative'; + // rating === 3 + const pos = countCueHits(reviewText, POSITIVE_CUES); + const neg = countCueHits(reviewText, NEGATIVE_CUES); + if (neg > pos + 1) return 'negative'; + if (pos > neg + 1) return 'positive'; + return 'mixed'; } // --------------------------------------------------------------------------- -// Topic extraction (keyword-based) +// Topic extraction (keyword-based, word-boundary match) // --------------------------------------------------------------------------- const TOPIC_KEYWORDS: Record = { @@ -69,15 +128,14 @@ const TOPIC_KEYWORDS: Record = { food: ['breakfast', 'restaurant', 'food', 'dining', 'meal', 'buffet', 'coffee', 'menu'], parking: ['parking', 'garage', 'valet', 'car'], wifi: ['wifi', 'wi-fi', 'internet', 'connection', 'signal'], - room_quality: ['room', 'bed', 'comfortable', 'view', 'bathroom', 'shower', 'towel', 'pillow', 'mattress', 'air conditioning', 'AC', 'heating'], + room_quality: ['room', 'bed', 'comfortable', 'view', 'bathroom', 'shower', 'towel', 'pillow', 'mattress', 'air conditioning', 'ac', 'heating'], }; export function extractTopics(text: string): ReviewTopic[] { - const lower = text.toLowerCase(); const found: ReviewTopic[] = []; for (const [topic, keywords] of Object.entries(TOPIC_KEYWORDS) as [ReviewTopic, string[]][]) { - if (keywords.some((kw) => lower.includes(kw))) { + if (keywords.some((kw) => keywordPattern(kw).test(text))) { found.push(topic); } } @@ -90,7 +148,7 @@ export function extractTopics(text: string): ReviewTopic[] { // --------------------------------------------------------------------------- export function analyzeReview(rating: number, reviewText: string): ReviewAnalysis { - const sentiment = classifySentiment(rating); + const sentiment = classifySentiment(rating, reviewText); const topics = extractTopics(reviewText); const urgency = sentiment === 'negative' ? 'high' : 'normal'; @@ -228,9 +286,15 @@ export function generateResponseDraft( } } - // Confidence based on topic coverage - const topicCoverage = analysis.topics.length > 0 ? addressedTopics.length / analysis.topics.length : 1; - const confidence = Math.min(0.95, 0.70 + topicCoverage * 0.20); + // Keyword hits are a weak signal — keep confidence modest so autopilot + // risk tiers treat these as drafts for humans, not "understood" certainty. + const topicCoverage = + analysis.topics.length > 0 ? addressedTopics.length / analysis.topics.length : 0; + const shortText = reviewText.trim().split(/\s+/).filter(Boolean).length < 8; + let confidence = 0.55 + topicCoverage * 0.2; + if (shortText) confidence -= 0.1; + if (analysis.topics.length === 0) confidence = Math.min(confidence, 0.5); + confidence = Math.max(0.35, Math.min(0.85, confidence)); return { responseText: response, From fb639a863376d49788c4b658a89382ee4e093e26 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 06:53:52 +0000 Subject: [PATCH 2/4] fix(llm): harden explain JSON parse and length caps Prefer whole-body JSON.parse, reject non-string rationale, and cap rationale/suggestion length before display. Co-authored-by: telivity-otaip --- apps/api/src/modules/llm/llm.service.spec.ts | 25 ++++++++++++++ apps/api/src/modules/llm/llm.service.ts | 35 +++++++++++++------- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/apps/api/src/modules/llm/llm.service.spec.ts b/apps/api/src/modules/llm/llm.service.spec.ts index 2ee78867..5e2fd740 100644 --- a/apps/api/src/modules/llm/llm.service.spec.ts +++ b/apps/api/src/modules/llm/llm.service.spec.ts @@ -119,4 +119,29 @@ describe('LlmService', () => { expect(out!.rationale).toBe('ok'); expect(out!.suggestions).toEqual([]); }); + + it('rejects payloads whose rationale is not a string', async () => { + process.env['HAIP_AI_ENABLED'] = 'true'; + vi.spyOn(globalThis, 'fetch' as any).mockResolvedValue({ + ok: true, + json: async () => ({ + message: { content: JSON.stringify({ rationale: { text: 'nope' }, suggestions: [] }) }, + }), + } as any); + expect(await makeService().explain(INPUT)).toBeNull(); + }); + + it('caps an overlong rationale', async () => { + process.env['HAIP_AI_ENABLED'] = 'true'; + const long = 'x'.repeat(900); + vi.spyOn(globalThis, 'fetch' as any).mockResolvedValue({ + ok: true, + json: async () => ({ + message: { content: JSON.stringify({ rationale: long, suggestions: ['a'.repeat(300)] }) }, + }), + } as any); + const out = await makeService().explain(INPUT); + expect(out!.rationale.length).toBe(600); + expect(out!.suggestions[0]!.length).toBe(200); + }); }); diff --git a/apps/api/src/modules/llm/llm.service.ts b/apps/api/src/modules/llm/llm.service.ts index 9f5bf8c9..d22976a9 100644 --- a/apps/api/src/modules/llm/llm.service.ts +++ b/apps/api/src/modules/llm/llm.service.ts @@ -123,25 +123,36 @@ export class LlmService { } } - /** Defensive JSON parse: tolerate stray prose around the JSON object. */ + /** Defensive JSON parse: prefer whole-body JSON, then tolerate stray prose. */ private parse(content: string): { rationale: string; suggestions: string[] } | null { + const tryParse = (raw: string): { rationale: string; suggestions: string[] } | null => { + try { + const obj = JSON.parse(raw) as { rationale?: unknown; suggestions?: unknown }; + if (typeof obj.rationale !== 'string') return null; + const rationale = obj.rationale.trim().slice(0, 600); + if (!rationale) return null; + const suggestions = Array.isArray(obj.suggestions) + ? obj.suggestions + .filter((s): s is string => typeof s === 'string' && s.trim().length > 0) + .map((s) => s.trim().slice(0, 200)) + .slice(0, 3) + : []; + return { rationale, suggestions }; + } catch { + return null; + } + }; + let raw = content.trim(); + const direct = tryParse(raw); + if (direct) return direct; + if (!raw.startsWith('{')) { const start = raw.indexOf('{'); const end = raw.lastIndexOf('}'); if (start === -1 || end === -1 || end <= start) return null; raw = raw.slice(start, end + 1); } - try { - const obj = JSON.parse(raw) as { rationale?: unknown; suggestions?: unknown }; - const rationale = typeof obj.rationale === 'string' ? obj.rationale.trim() : ''; - if (!rationale) return null; - const suggestions = Array.isArray(obj.suggestions) - ? obj.suggestions.filter((s): s is string => typeof s === 'string' && s.trim().length > 0).slice(0, 3) - : []; - return { rationale, suggestions }; - } catch { - return null; - } + return tryParse(raw); } } From 4c319aa3ff3e8ab706681634ce07a5dc67fbebde Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 06:53:52 +0000 Subject: [PATCH 3/4] fix(import): reuse guests by email and alias common CSV headers Find-or-create guests like channel/Connect when already linked at the property, dedupe within a batch, and map Guest E-mail style headers. Co-authored-by: telivity-otaip --- apps/api/src/modules/import/csv.util.ts | 87 ++++++++++++++++++- .../src/modules/import/import.service.spec.ts | 60 ++++++++++++- apps/api/src/modules/import/import.service.ts | 55 +++++++++++- apps/dashboard/src/pages/Import.tsx | 60 ++++++++++++- 4 files changed, 251 insertions(+), 11 deletions(-) diff --git a/apps/api/src/modules/import/csv.util.ts b/apps/api/src/modules/import/csv.util.ts index 43b36ed5..9b8b0b30 100644 --- a/apps/api/src/modules/import/csv.util.ts +++ b/apps/api/src/modules/import/csv.util.ts @@ -4,6 +4,72 @@ * row objects keyed by the header row. Dependency-free — migration files are * modest and this avoids pulling in a parser library. */ + +/** Common PMS export headers → HAIP canonical import fields. */ +const HEADER_ALIASES: Record = { + 'first name': 'firstName', + firstname: 'firstName', + 'guest first name': 'firstName', + 'given name': 'firstName', + 'last name': 'lastName', + lastname: 'lastName', + surname: 'lastName', + 'guest last name': 'lastName', + 'family name': 'lastName', + email: 'email', + 'e-mail': 'email', + 'guest email': 'email', + 'guest e-mail': 'email', + 'email address': 'email', + phone: 'phone', + telephone: 'phone', + mobile: 'phone', + 'phone number': 'phone', + company: 'companyName', + 'company name': 'companyName', + loyalty: 'loyaltyNumber', + 'loyalty number': 'loyaltyNumber', + 'loyalty #': 'loyaltyNumber', + arrival: 'checkIn', + 'check in': 'checkIn', + 'check-in': 'checkIn', + departure: 'checkOut', + 'check out': 'checkOut', + 'check-out': 'checkOut', + 'room type': 'roomTypeId', + 'room type id': 'roomTypeId', + 'rate plan': 'ratePlanId', + 'rate plan id': 'ratePlanId', + 'room number': 'number', + 'room #': 'number', +}; + +export function normalizeHeaderKey(header: string): string { + return header + .trim() + .toLowerCase() + .replace(/[_/]+/g, ' ') + .replace(/-+/g, '-') + .replace(/\s+/g, ' ') + .trim(); +} + +/** Map a source CSV header to a canonical field when it matches by name or alias. */ +export function suggestCanonicalField( + sourceHeader: string, + canonicalFields: string[], +): string | undefined { + const canonLower = new Map(canonicalFields.map((c) => [c.toLowerCase(), c])); + const exact = canonLower.get(sourceHeader.trim().toLowerCase()); + if (exact) return exact; + + const alias = HEADER_ALIASES[normalizeHeaderKey(sourceHeader)]; + if (alias && canonLower.has(alias.toLowerCase())) { + return canonLower.get(alias.toLowerCase()); + } + return undefined; +} + export function parseCsv(text: string): Record[] { const rows = parseRows(text); if (rows.length === 0) return []; @@ -71,16 +137,29 @@ function parseRows(text: string): string[][] { * column name (as it appears in the CSV) to the importer's canonical field name. * Columns absent from the mapping pass through unchanged so a CSV whose headers * already match needs no mapping at all. + * + * When `canonicalFields` is provided and a source column is unmapped, common + * PMS header aliases are applied automatically. */ export function applyMapping( row: Record, mapping?: Record, + canonicalFields?: string[], ): Record { - if (!mapping || Object.keys(mapping).length === 0) return row; const out: Record = { ...row }; - for (const [source, canonical] of Object.entries(mapping)) { - if (source in row) { - out[canonical] = row[source]!; + if (mapping) { + for (const [source, canonical] of Object.entries(mapping)) { + if (canonical && source in row) { + out[canonical] = row[source]!; + } + } + } + if (canonicalFields?.length) { + for (const source of Object.keys(row)) { + const suggested = suggestCanonicalField(source, canonicalFields); + if (suggested && (out[suggested] === undefined || out[suggested] === '')) { + out[suggested] = row[source]!; + } } } return out; diff --git a/apps/api/src/modules/import/import.service.spec.ts b/apps/api/src/modules/import/import.service.spec.ts index 27d22f64..ead9d2be 100644 --- a/apps/api/src/modules/import/import.service.spec.ts +++ b/apps/api/src/modules/import/import.service.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { BadRequestException } from '@nestjs/common'; import { ImportService } from './import.service'; -import { parseCsv, applyMapping } from './csv.util'; +import { parseCsv, applyMapping, suggestCanonicalField } from './csv.util'; const PROP = 'aaaaaaaa-0000-4000-a000-000000000001'; @@ -28,6 +28,24 @@ describe('applyMapping', () => { expect(mapped.firstName).toBe('Ada'); expect(mapped.lastName).toBe('Lovelace'); }); + + it('applies common header aliases when canonical fields are provided', () => { + const mapped = applyMapping( + { 'Guest E-mail': 'ada@x.com', 'First Name': 'Ada', Surname: 'Lovelace' }, + undefined, + ['firstName', 'lastName', 'email'], + ); + expect(mapped.email).toBe('ada@x.com'); + expect(mapped.firstName).toBe('Ada'); + expect(mapped.lastName).toBe('Lovelace'); + }); +}); + +describe('suggestCanonicalField', () => { + it('maps guest e-mail style headers to email', () => { + expect(suggestCanonicalField('Guest E-mail', ['firstName', 'lastName', 'email'])).toBe('email'); + expect(suggestCanonicalField('arrival', ['checkIn', 'checkOut'])).toBe('checkIn'); + }); }); describe('ImportService', () => { @@ -49,10 +67,11 @@ describe('ImportService', () => { createAutoFolio: vi.fn().mockResolvedValue({ id: 'folio-1' }), postCharge: vi.fn().mockResolvedValue({ id: 'charge-1' }), }; + // Default: no existing guests (import create path). Folio tests override. db = { select: vi.fn(() => ({ from: vi.fn(() => ({ - where: vi.fn(() => Promise.resolve([{ id: 'res-1', propertyId: PROP, guestId: 'g-1', bookingId: 'b-1' }])), + where: vi.fn(() => Promise.resolve([])), })), })), }; @@ -112,6 +131,43 @@ describe('ImportService', () => { expect(guest.create).toHaveBeenCalledWith(expect.objectContaining({ firstName: 'Ada', lastName: 'Lovelace' })); }); + it('dedupes guests by email within the same import batch', async () => { + guest.create + .mockResolvedValueOnce({ id: 'g-1' }) + .mockResolvedValueOnce({ id: 'g-2' }); + const csv = + 'firstName,lastName,email\nAda,Lovelace,ada@x.com\nAda,Lovelace,ada@x.com'; + const res = await svc.run(PROP, 'guests', { csv }); + expect(guest.create).toHaveBeenCalledOnce(); + expect(res.created).toBe(2); + expect(res.results[0]!.id).toBe('g-1'); + expect(res.results[1]!.id).toBe('g-1'); + }); + + it('reuses a guest already linked to the property by email', async () => { + db.select = vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn() + .mockResolvedValueOnce([{ id: 'g-existing', email: 'ada@x.com' }]) + .mockResolvedValueOnce([{ id: 'res-1' }]), + })), + })); + const res = await svc.run(PROP, 'guests', { + rows: [{ firstName: 'Ada', lastName: 'Lovelace', email: 'ada@x.com' }], + }); + expect(guest.create).not.toHaveBeenCalled(); + expect(res.results[0]!.id).toBe('g-existing'); + }); + + it('auto-maps Guest E-mail style headers without an explicit mapping', async () => { + const csv = 'First Name,Last Name,Guest E-mail\nAda,Lovelace,ada@x.com'; + const res = await svc.run(PROP, 'guests', { csv }); + expect(res.created).toBe(1); + expect(guest.create).toHaveBeenCalledWith( + expect.objectContaining({ firstName: 'Ada', lastName: 'Lovelace', email: 'ada@x.com' }), + ); + }); + it('imports physical rooms scoped to propertyId', async () => { const res = await svc.run(PROP, 'rooms', { rows: [{ roomTypeId: 'rt-1', number: '101' }], diff --git a/apps/api/src/modules/import/import.service.ts b/apps/api/src/modules/import/import.service.ts index 4dceacae..85a8334a 100644 --- a/apps/api/src/modules/import/import.service.ts +++ b/apps/api/src/modules/import/import.service.ts @@ -1,6 +1,6 @@ import { Injectable, BadRequestException, Inject } from '@nestjs/common'; import { and, eq } from 'drizzle-orm'; -import { folios, reservations } from '@telivityhaip/database'; +import { folios, guests, reservations } from '@telivityhaip/database'; import { DRIZZLE } from '../../database/database.module'; import { GuestService } from '../guest/guest.service'; import { RoomService } from '../room/room.service'; @@ -183,6 +183,8 @@ export class ImportService { ): Promise { const imp = this.requireImporter(entity); const dryRun = input.dryRun ?? false; + /** Same-batch email → guest id so duplicate CSV rows reuse one create. */ + const guestEmailCache = new Map(); const rawRows = input.csv ? parseCsv(input.csv) : input.rows ?? []; if (rawRows.length === 0) { @@ -191,7 +193,7 @@ export class ImportService { const results: RowResult[] = []; for (let i = 0; i < rawRows.length; i++) { - const mapped = applyMapping(rawRows[i]!, input.mapping); + const mapped = applyMapping(rawRows[i]!, input.mapping, imp.columns); try { await this.resolveLegacyRefs(mapped, propertyId, input); // Validate required columns. @@ -203,6 +205,9 @@ export class ImportService { const dto = imp.build(mapped, propertyId); if (dryRun) { results.push({ index: i, success: true }); + } else if (entity === 'guests') { + const created = await this.findOrCreateImportedGuest(propertyId, dto, guestEmailCache); + results.push({ index: i, success: true, id: created.id }); } else { const created = await imp.create(dto); results.push({ index: i, success: true, id: created.id }); @@ -222,6 +227,52 @@ export class ImportService { }; } + /** + * Reuse a guest by email when they already have a reservation at this property + * (same rule as Connect/channel inbound), or when an earlier row in this batch + * already created them. Otherwise create via GuestService. + */ + private async findOrCreateImportedGuest( + propertyId: string, + dto: { + firstName: string; + lastName: string; + email?: string; + phone?: string; + companyName?: string; + loyaltyNumber?: string; + }, + emailCache: Map, + ): Promise<{ id: string }> { + const email = dto.email?.trim().toLowerCase(); + if (email && emailCache.has(email)) { + return { id: emailCache.get(email)! }; + } + + if (email) { + const matches = await this.db.select().from(guests).where(eq(guests.email, dto.email!.trim())); + for (const candidate of matches) { + const links = await this.db + .select({ id: reservations.id }) + .from(reservations) + .where( + and(eq(reservations.guestId, candidate.id), eq(reservations.propertyId, propertyId)), + ); + if (links.length > 0) { + emailCache.set(email, candidate.id); + return { id: candidate.id }; + } + } + } + + const created = await this.guestService.create({ + ...dto, + email: dto.email?.trim() || undefined, + }); + if (email) emailCache.set(email, created.id); + return created; + } + private async resolveLegacyRefs( row: Record, propertyId: string, diff --git a/apps/dashboard/src/pages/Import.tsx b/apps/dashboard/src/pages/Import.tsx index 22ad294e..1d91fbba 100644 --- a/apps/dashboard/src/pages/Import.tsx +++ b/apps/dashboard/src/pages/Import.tsx @@ -39,6 +39,61 @@ interface ImportResult { const UNMAPPED = ''; +/** Common PMS export headers → HAIP canonical fields (keep in sync with API csv.util). */ +const HEADER_ALIASES: Record = { + 'first name': 'firstName', + firstname: 'firstName', + 'guest first name': 'firstName', + 'given name': 'firstName', + 'last name': 'lastName', + lastname: 'lastName', + surname: 'lastName', + 'guest last name': 'lastName', + 'family name': 'lastName', + email: 'email', + 'e-mail': 'email', + 'guest email': 'email', + 'guest e-mail': 'email', + 'email address': 'email', + phone: 'phone', + telephone: 'phone', + mobile: 'phone', + 'phone number': 'phone', + company: 'companyName', + 'company name': 'companyName', + loyalty: 'loyaltyNumber', + 'loyalty number': 'loyaltyNumber', + arrival: 'checkIn', + 'check in': 'checkIn', + 'check-in': 'checkIn', + departure: 'checkOut', + 'check out': 'checkOut', + 'check-out': 'checkOut', + 'room type': 'roomTypeId', + 'room type id': 'roomTypeId', + 'rate plan': 'ratePlanId', + 'rate plan id': 'ratePlanId', + 'room number': 'number', +}; + +function normalizeHeaderKey(header: string): string { + return header + .trim() + .toLowerCase() + .replace(/[_/]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function suggestCanonical(sourceHeader: string, canonicalFields: string[]): string | undefined { + const lower = new Map(canonicalFields.map((c) => [c.toLowerCase(), c])); + const exact = lower.get(sourceHeader.trim().toLowerCase()); + if (exact) return exact; + const alias = HEADER_ALIASES[normalizeHeaderKey(sourceHeader)]; + if (alias && lower.has(alias.toLowerCase())) return lower.get(alias.toLowerCase()); + return undefined; +} + /** Parse a single CSV line, honoring double-quoted fields with embedded commas. */ function parseCsvLine(line: string): string[] { const out: string[] = []; @@ -109,13 +164,12 @@ export default function Import() { const dataRowCount = useMemo(() => Math.max(0, splitLines(csvText).length - 1), [csvText]); - /** Build a default mapping (exact-name match) for the current columns + entity. */ + /** Build a default mapping (exact name or common PMS header alias). */ function buildDefaultMapping(cols: string[], ent: ImportEntity | null): Record { const map: Record = {}; const canonical = ent?.columns ?? []; for (const col of cols) { - const exact = canonical.find((c) => c.toLowerCase() === col.toLowerCase()); - map[col] = exact ?? UNMAPPED; + map[col] = suggestCanonical(col, canonical) ?? UNMAPPED; } return map; } From f882da85830db19c0215e8eda7e4267397199039 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 06:59:19 +0000 Subject: [PATCH 4/4] chore: sync published test counts after harden specs Account for new review, LLM parse, and import unit cases so the README stale-count gate matches CI. Co-authored-by: telivity-otaip --- README.md | 8 ++++---- docs/test-stats.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3cdd0b5e..985b65f6 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ NestJS PostgreSQL Apache 2.0 License - 2407 Tests Passing 12 AI Agents + 2418 Tests Passing 12 AI Agents

@@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire | OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) | | XML Processing | fast-xml-parser | Booking.com OTA XML protocol | | Package Manager | pnpm workspaces | Monorepo management | -| Testing | Vitest (2407 passing tests across 281 files with passing tests) | Unit and integration tests | +| Testing | Vitest (2418 passing tests across 281 files with passing tests) | Unit and integration tests | | Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | | Containers | Docker + docker-compose | Local dev and production deployment | | CI/CD | GitHub Actions | Automated testing, builds, and releases | @@ -648,7 +648,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment. ### Run tests ```bash -# Passing-test count: 2407 test cases across 281 files (skipped excluded) +# Passing-test count: 2418 test cases across 281 files (skipped excluded) # API tests only pnpm --filter @telivityhaip/api test @@ -1197,7 +1197,7 @@ HAIP is built in public and contributions are welcome. pnpm install # Install dependencies pnpm build # Build all workspace packages pnpm dev # Start API in dev mode (hot reload) -pnpm test # Run all tests (2407 passing, 281 files with passes; skipped excluded) +pnpm test # Run all tests (2418 passing, 281 files with passes; skipped excluded) pnpm lint # ESLint ``` diff --git a/docs/test-stats.json b/docs/test-stats.json index 8c7ade4d..3b3b1c36 100644 --- a/docs/test-stats.json +++ b/docs/test-stats.json @@ -1,7 +1,7 @@ { - "tests": 2407, + "tests": 2418, "files": 281, "scope": "all workspace packages with a test script", "semantics": "passed test cases and files containing at least one passed test; skipped test cases and skipped-only files are excluded", - "updatedAt": "2026-09-24T06:33:10.149Z" + "updatedAt": "2026-09-24T06:59:00.000Z" }