Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/NestJS-framework-E0234E?logo=nestjs&logoColor=white" alt="NestJS" />
<img src="https://img.shields.io/badge/PostgreSQL-database-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL" />
<img src="https://img.shields.io/badge/License-Apache%202.0-blue" alt="Apache 2.0 License" />
<img src="https://img.shields.io/badge/Tests-2407%20passing-brightgreen" alt="2407 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
<img src="https://img.shields.io/badge/Tests-2418%20passing-brightgreen" alt="2418 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
</p>

<p align="center">
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -180,24 +205,25 @@ 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,
'Dirty room, noisy, terrible wifi, overpriced',
'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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(`(?<![a-z0-9])${escaped}(?![a-z0-9])`, 'i');
}

function countCueHits(text: string, cues: string[]): number {
let n = 0;
for (const cue of cues) {
if (keywordPattern(cue).test(text)) n += 1;
}
return n;
}

/**
* Stars are the primary signal. For a middle rating (3), lean on simple
* positive/negative word cues so "staff was great but room was tiny" stays
* mixed while an all-complaint 3 drifts negative (and all-praise drifts positive).
*/
export function classifySentiment(rating: number, reviewText = ''): ReviewSentiment {
if (rating >= 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<ReviewTopic, string[]> = {
Expand All @@ -69,15 +128,14 @@ const TOPIC_KEYWORDS: Record<ReviewTopic, string[]> = {
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);
}
}
Expand All @@ -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';

Expand Down Expand Up @@ -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,
Expand Down
87 changes: 83 additions & 4 deletions apps/api/src/modules/import/csv.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'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<string, string>[] {
const rows = parseRows(text);
if (rows.length === 0) return [];
Expand Down Expand Up @@ -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<string, string>,
mapping?: Record<string, string>,
canonicalFields?: string[],
): Record<string, string> {
if (!mapping || Object.keys(mapping).length === 0) return row;
const out: Record<string, string> = { ...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;
Expand Down
Loading
Loading