/** * 매칭 규칙 테이블. * * 두 종류가 있다. * · 서브 질의 빌더 — 프로필을 속성별로 쪼개 각각 임베딩한다 (통짜로 넣으면 속성이 희석된다) * · 사실 기반 필터 — 벡터가 못 거르는 모순을 SQL/코드 조건으로 배제한다 * (임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다) */ export interface MerchantFacts { name: string; region: string | null; industry: string | null; description: string; address: string | null; areaGroup: AreaGroup | null; capacityMax: number | null; services: string[]; features: string[]; audiences: string[]; nearby: string[]; amenities: Set; // 정규화된 보유 시설 unverified: Set; // 미확인 — 배제하지 않고 보류 처리 /** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. 사업자가 쓰는 말과 다르므로 별도 레인으로 둔다 */ signals: string[]; } export type AreaGroup = '해안·도서' | '원도심' | '시내'; export interface Lane { key: string; label: string; weight: number; text: string; } // ──────────────────────────────────────────── 권역 판정 const AREA_TERMS: Record = { '해안·도서': ['선유도', '무녀도', '장자도', '대장도', '신시도', '야미도', '고군산군도', '새만금', '비응항', '비응도', '오식도', '해수욕장', '몽돌', '오션뷰', '바다뷰', '해변', '섬'], '원도심': ['원도심', '신흥동', '영화동', '월명', '말랭이마을', '동국사', '초원사진관', '이성당', '경암동', '근대역사', '근대문화', '시간여행', '해망굴', '째보선창', '뜬다리', '일본식가옥'], '시내': ['나운동', '수송동', '미룡동', '조촌동', '은파호수공원', '군산역', '시외버스'], }; export function detectAreaGroup(text: string): AreaGroup | null { const hits = (Object.entries(AREA_TERMS) as [AreaGroup, string[]][]) .map(([g, terms]) => [g, terms.filter((t) => text.includes(t)).length] as const) .filter(([, n]) => n > 0) .sort((a, b) => b[1] - a[1]); return hits[0]?.[0] ?? null; } /** 키워드가 어느 권역에 속하는지 (해당 없으면 null = 권역 중립) */ export function keywordAreaGroup(keyword: string): AreaGroup | null { return detectAreaGroup(keyword); } // ──────────────────────────────────────────── 수용 인원 모순 const GROUP_TERMS = ['단체', '워크샵', '워크숍', 'mt', '엠티', '20인', '15인', '10인', '세미나', '단합', '대형', '펜션동', '전체대관']; const GROUP_MIN_CAPACITY = 8; export function violatesCapacity(keyword: string, capacityMax: number | null): boolean { if (capacityMax === null || capacityMax >= GROUP_MIN_CAPACITY) return false; const k = keyword.toLowerCase(); return GROUP_TERMS.some((t) => k.includes(t)); } // ──────────────────────────────────────────── 시설 요구 조건 /** 키워드에 이 말이 있으면 해당 시설을 실제로 보유해야 한다 */ const AMENITY_REQUIRED: Array<[RegExp, string]> = [ [/바베큐|바비큐|바베큐장|그릴/, '바베큐'], [/수영장|풀빌라|인피니티풀|온수풀|야외수영/, '수영장'], [/스파|자쿠지|월풀|반신욕/, '스파'], [/애견|반려|펫/, '애견동반'], [/주차/, '주차'], [/노래방/, '노래방'], [/조식/, '조식'], [/키즈룸|트램폴린/, '키즈시설'], [/불멍|화로대|캠프파이어/, '불멍'], [/빔프로젝터|넷플릭스/, '미디어'], [/세미나실/, '세미나실'], ]; /** 시설 표기 흔들림을 통제 어휘로 모은다 */ const AMENITY_SYNONYMS: Array<[RegExp, string]> = [ [/바베큐|바비큐|그릴/, '바베큐'], [/수영장|풀|풀빌라/, '수영장'], [/스파|자쿠지|월풀|욕조/, '스파'], [/애견|반려|펫/, '애견동반'], [/주차/, '주차'], [/노래방/, '노래방'], [/조식|아침/, '조식'], [/키즈|트램폴린|유아/, '키즈시설'], [/불멍|화로|캠프파이어/, '불멍'], [/넷플릭스|빔프로젝터|ott/i, '미디어'], [/세미나/, '세미나실'], ]; export function normalizeAmenities(raw: string[]): Set { const out = new Set(); for (const r of raw) { for (const [re, canon] of AMENITY_SYNONYMS) if (re.test(r)) out.add(canon); } return out; } export type AmenityVerdict = { ok: true } | { ok: false; hold: boolean; amenity: string }; export function checkAmenity(keyword: string, facts: MerchantFacts): AmenityVerdict { for (const [re, amenity] of AMENITY_REQUIRED) { if (!re.test(keyword)) continue; if (facts.amenities.has(amenity)) return { ok: true }; // 사업자가 확인해주지 않은 항목은 "없음"이 아니라 "모름" — 배제하지 않고 보류 const unverified = [...facts.unverified].some((u) => AMENITY_SYNONYMS.some(([sre, canon]) => canon === amenity && sre.test(u))); return { ok: false, hold: unverified, amenity }; } return { ok: true }; } // ──────────────────────────────────────────── 서브 질의 빌더 const STAY_TYPE_HINTS = ['독채', '풀빌라', '스테이', '펜션', '글램핑', '카라반', '한옥', '민박', '감성']; const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모|수용/; /** * 레인 설계 원칙 * 1. 레인끼리 겹치지 않게 한다. 모든 레인에 "군산 펜션"을 넣으면 레인이 상관되고, * 그러면 RRF 가 "여러 레인에 두루 걸린 generic 키워드"를 상위로 올린다. * 지역+업종 앵커는 유형 레인에만 둔다. * 2. 브랜드 레인은 두지 않는다. 상호는 사전에 없으므로 결국 "군산 펜션"만 남아 * 가장 generic 한 것들을 끌어온다 (실측에서 상위 6개가 전부 '~예약'으로 도배됐다). * 3. 수용 인원은 레인에 넣지 않는다. 필터 전용이다. */ export function buildLanes(f: MerchantFacts): Lane[] { const lanes: Lane[] = []; // 토큰 단위로 중복을 제거한다. 문자열 단위 Set 만으로는 '신흥동' 과 // '신흥동 일본식가옥' 이 서로 다른 원소라 같은 낱말이 두 번 실리고, // 그 낱말 쪽으로 레인이 쏠린다 (실제로 말랭이마을이 밀려났다). const push = (key: string, label: string, weight: number, parts: (string | null | undefined)[]) => { const seen = new Set(); const words: string[] = []; for (const part of parts) { if (!part) continue; for (const w of String(part).split(/\s+/)) { const k = w.trim(); if (!k || seen.has(k)) continue; seen.add(k); words.push(k); } } const text = words.join(' '); if (text) lanes.push({ key, label, weight, text }); }; const isType = (x: string) => STAY_TYPE_HINTS.some((h) => x.includes(h)); const typeWords = [...f.features, ...f.services].filter(isType).slice(0, 3); // 시설: 유형어·수용인원·권역어를 걷어낸 나머지 + 정규화된 보유 시설 const amenityWords = [ ...f.amenities, ...f.features.filter((x) => !isType(x) && !CAPACITY_TOKEN.test(x) && !keywordAreaGroup(x)), ].slice(0, 6); // 권역과 인근을 한 레인으로 합친다. 나눠 두면 '신흥동' 같은 토큰이 두 레인에 겹쳐 // 같은 위치 키워드가 두 번 가산되고, 상위가 전부 위치 키워드로 쓸려 나간다. push('type', '유형', 1.0, [f.region, f.industry, ...typeWords]); push('place', '위치', 0.7, [f.areaGroup, districtOf(f.address), ...f.nearby.slice(0, 4), '근처']); push('audience', '동반자', 0.6, f.audiences.slice(0, 4)); push('amenity', '시설', 0.6, amenityWords); // 고객 언어는 사업자 표현보다 검색어에 가깝다 — 데이터가 있으면 높게 잡는다 push('signal', '고객언어', 0.9, f.signals.slice(0, 10)); return lanes; } function districtOf(address: string | null): string | null { if (!address) return null; const m = address.match(/([가-힣]+(?:동|읍|면|리))/); return m?.[1] ?? null; }