import { Injectable } from '@nestjs/common'; import { EMBEDDING_DIM } from '../config/env'; import { normalizeKeyword } from '../keywords/normalize'; import { GenerationOutput, KeywordCandidate, KeywordIntent, LlmProvider, MerchantContext, QaCandidate, } from './types'; /** * API 키 없이 로컬에서 전체 파이프라인(생성 → 중복제거 → 서빙)을 돌리기 위한 대체 구현. * * embed(): 문자 bigram 해싱 + L2 정규화. * 랜덤이 아니라 "비슷한 문자열이면 비슷한 벡터"가 나오므로 * 코사인 임계값 기반 중복제거 동작을 실제와 유사하게 검증할 수 있다. */ @Injectable() export class MockLlmProvider extends LlmProvider { readonly name = 'mock'; readonly model = 'mock-keyword-v1'; async generate(ctx: MerchantContext): Promise { const region = ctx.regionName ?? ''; const industry = ctx.industryName ?? '업체'; const p = ctx.profile; const services = toStringArray(p['services']); const features = toStringArray(p['features']); const audiences = toStringArray(p['audiences']); const nearby = toStringArray(p['nearby']); const seasons = toStringArray(p['seasons']); const MODIFIERS = ['추천', '예약', '가격', '후기', '저렴한곳', '깨끗한', '인기', '순위', '위치', '실시간예약']; const out: Array<[string, KeywordIntent, number]> = []; const push = (k: string, intent: KeywordIntent, rel: number) => out.push([k, intent, rel]); // 실제 로컬 검색 패턴을 프로필 배열의 조합으로 전개한다. push(ctx.name, 'brand', 0.99); push(`${region} ${ctx.name}`, 'brand', 0.97); push(`${region} ${industry}`, 'local', 0.95); push(`${region} ${industry} 추천`, 'local', 0.93); for (const m of MODIFIERS) { push(`${region} ${industry} ${m}`, intentOf(m), 0.86); } for (const a of audiences) { push(`${region} ${a} ${industry}`, 'local', 0.88); for (const m of MODIFIERS.slice(0, 4)) push(`${region} ${a} ${industry} ${m}`, intentOf(m), 0.74); push(`${a} ${industry} 추천`, 'informational', 0.62); } for (const f of features) { push(`${region} ${f} ${industry}`, 'local', 0.84); push(`${industry} ${f}`, 'informational', 0.6); push(`${region} ${industry} ${f}`, 'local', 0.7); } for (const s of services) { push(`${region} ${s}`, 'local', 0.82); for (const m of MODIFIERS.slice(0, 4)) push(`${s} ${m}`, intentOf(m), 0.66); } for (const n of nearby) { push(`${n} 근처 ${industry}`, 'local', 0.8); push(`${n} ${industry} 추천`, 'local', 0.76); push(`${n} 숙소`, 'local', 0.68); } for (const s of seasons) { push(`${s} ${region} ${industry}`, 'local', 0.72); push(`${region} ${s} ${industry} 예약`, 'transactional', 0.64); } // 동반자 × 시설 롱테일 — 여기서부터 검색량이 급격히 얇아진다 for (const a of audiences) { for (const f of features) push(`${region} ${a} ${f} ${industry}`, 'local', 0.42); } for (const a of audiences) { for (const s of services) push(`${a} ${s}`, 'informational', 0.38); } // 질문형 (AEO 유입) for (const a of audiences) push(`${region} ${a} ${industry} 어디가 좋을까요`, 'informational', 0.5); for (const n of nearby) push(`${n} 여행 ${industry} 어디`, 'informational', 0.44); const seen = new Set(); const keywords: KeywordCandidate[] = []; for (const [raw, intent, relevance] of out) { const k = raw.replace(/\s+/g, ' ').trim(); if (!k || seen.has(k)) continue; seen.add(k); keywords.push({ keyword: k, intent, relevance, rationale: `mock: ${intent}` }); if (keywords.length >= ctx.targetCount) break; } const qaPairs: QaCandidate[] = [ { question: `${ctx.name}은(는) 어디에 있나요?`, answer: `${ctx.name}은(는) ${region || '해당 지역'}에 위치한 ${industry}입니다.`, }, { question: `${ctx.name} 예약은 어떻게 하나요?`, answer: `${ctx.name}은(는) 사이트 예약 페이지 또는 전화로 예약할 수 있습니다.`, }, { question: `${ctx.name}의 주요 서비스는 무엇인가요?`, answer: services.length ? `주요 서비스는 ${services.join(', ')} 입니다.` : `${industry} 관련 서비스를 제공합니다.`, }, { question: `${ctx.name} 근처에 가볼 만한 곳은 어디인가요?`, answer: nearby.length ? `${nearby.join(', ')} 등이 가깝습니다.` : `${region} 주요 명소가 인근에 있습니다.`, }, { question: `${ctx.name}에 ${audiences[0] ?? '반려동물'}도 갈 수 있나요?`, answer: features.length ? `${features.join(', ')} 조건을 제공합니다. 예약 전 상세 조건을 확인해 주세요.` : `예약 전 상세 조건을 확인해 주세요.`, }, ]; return { keywords, qaPairs, model: this.model, provider: this.name }; } } function intentOf(modifier: string): KeywordIntent { if (modifier === '예약' || modifier === '실시간예약' || modifier === '가격') return 'transactional'; if (modifier === '후기' || modifier === '순위') return 'informational'; return 'local'; } function toStringArray(v: unknown): string[] { return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; } /** 문자 bigram 해싱 임베딩 (결정적, L2 정규화) */ export function hashEmbedding(text: string, dim = EMBEDDING_DIM): number[] { const s = ` ${normalizeKeyword(text)} `; const vec = new Float64Array(dim); for (let i = 0; i < s.length - 1; i++) { const gram = s.slice(i, i + 2); const h = fnv1a(gram); vec[h % dim] += 1; // 부호 해싱으로 충돌 편향 완화 vec[(h >>> 8) % dim] += h & 1 ? 1 : -1; } let norm = 0; for (let i = 0; i < dim; i++) norm += vec[i] * vec[i]; norm = Math.sqrt(norm) || 1; return Array.from(vec, (x) => x / norm); } function fnv1a(str: string): number { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h >>> 0; }