군산 스테이머뭄 펜션 시드 추가 및 대량 생성 테스트 지원

- stay.pension 업종, kr.jeonbuk.gunsan 지역 계층 추가
- 스테이머뭄 업체 시드 (services/features/audiences/nearby/seasons 프로필)
- mock provider 를 프로필 배열 조합 기반으로 확장해
  실제 로컬 검색 패턴(동반자·시설·인근 관광지·시즌·질문형) 전개
- POST /v1/merchants/:id/generate?count=N 으로 생성 개수 오버라이드
- tsconfig.build.json 추가 — scripts/ 가 dist 루트를 바꾸던 문제 수정

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-09 13:32:13 +09:00
parent 209a381091
commit 81a3407897
5 changed files with 104 additions and 24 deletions

View File

@ -8,6 +8,8 @@ const industries = [
['food.korean', 'food.korean', '한식당'], ['food.korean', 'food.korean', '한식당'],
['health', 'health', '의료'], ['health', 'health', '의료'],
['health.dental', 'health.dental', '치과'], ['health.dental', 'health.dental', '치과'],
['stay', 'stay', '숙박'],
['stay.pension', 'stay.pension', '펜션'],
]; ];
const regions = [ const regions = [
@ -17,6 +19,8 @@ const regions = [
['kr.seoul.mapo', 'kr.seoul.mapo', '마포'], ['kr.seoul.mapo', 'kr.seoul.mapo', '마포'],
['kr.busan', 'kr.busan', '부산'], ['kr.busan', 'kr.busan', '부산'],
['kr.busan.haeundae', 'kr.busan.haeundae', '해운대'], ['kr.busan.haeundae', 'kr.busan.haeundae', '해운대'],
['kr.jeonbuk', 'kr.jeonbuk', '전북'],
['kr.jeonbuk.gunsan', 'kr.jeonbuk.gunsan', '군산'],
]; ];
const merchants = [ const merchants = [
@ -59,6 +63,24 @@ const merchants = [
priceRange: '25,000~80,000원', priceRange: '25,000~80,000원',
}, },
}, },
{
externalId: 'site-3001',
name: '스테이머뭄',
industryId: 'stay.pension',
regionId: 'kr.jeonbuk.gunsan',
description:
'군산 고군산군도 초입에 자리한 독채 펜션. 전 객실 오션뷰, 프라이빗 스파와 바베큐장을 갖췄다.',
siteUrl: 'https://stay-meomum.example.com',
profile: {
services: ['독채 펜션', '프라이빗 스파', '바베큐장', '조식 제공', 'picnic 세팅'],
features: ['오션뷰', '애견동반', '야외 수영장', '무료 주차', '넷플릭스', '스파 욕조', '단체 대여'],
audiences: ['커플', '가족', '친구', '애견동반', '단체 워크샵', '태교여행'],
nearby: ['선유도', '은파호수공원', '경암동 철길마을', '근대역사박물관', '새만금', '고군산군도'],
seasons: ['여름휴가', '겨울', '연말', '크리스마스', '벚꽃시즌'],
priceRange: '150,000~380,000원',
rooms: 6,
},
},
]; ];
async function main() { async function main() {

View File

@ -41,6 +41,7 @@ export class GenerationService {
async runForMerchant( async runForMerchant(
idOrExternalId: string, idOrExternalId: string,
trigger: GenerationTrigger = 'manual', trigger: GenerationTrigger = 'manual',
targetCount = env.generation.targetKeywords,
): Promise<GenerationStats> { ): Promise<GenerationStats> {
const startedAt = Date.now(); const startedAt = Date.now();
const merchant = await this.merchants.findWithTaxonomy(idOrExternalId); const merchant = await this.merchants.findWithTaxonomy(idOrExternalId);
@ -64,7 +65,7 @@ export class GenerationService {
regionPath: merchant.region_path, regionPath: merchant.region_path,
profile: merchant.profile ?? {}, profile: merchant.profile ?? {},
existingKeywords: existing, existingKeywords: existing,
targetCount: env.generation.targetKeywords, targetCount,
}; };
const output = await this.llm.generate(ctx); const output = await this.llm.generate(ctx);

View File

@ -29,38 +29,68 @@ export class MockLlmProvider extends LlmProvider {
async generate(ctx: MerchantContext): Promise<GenerationOutput> { async generate(ctx: MerchantContext): Promise<GenerationOutput> {
const region = ctx.regionName ?? ''; const region = ctx.regionName ?? '';
const industry = ctx.industryName ?? '업체'; const industry = ctx.industryName ?? '업체';
const services = toStringArray(ctx.profile['services']); const p = ctx.profile;
const features = toStringArray(ctx.profile['features']); 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 MODIFIERS = ['추천', '예약', '가격', '후기', '저렴한곳', '깨끗한', '인기', '순위', '위치', '실시간예약'];
const raw: Array<[string, KeywordIntent, number]> = []; const out: Array<[string, KeywordIntent, number]> = [];
const push = (k: string, intent: KeywordIntent, rel: number) => out.push([k, intent, rel]);
raw.push([`${region} ${industry}`.trim(), 'local', 0.95]); // 실제 로컬 검색 패턴을 프로필 배열의 조합으로 전개한다.
raw.push([ctx.name, 'brand', 0.99]); push(ctx.name, 'brand', 0.99);
for (const m of modifiers) { push(`${region} ${ctx.name}`, 'brand', 0.97);
raw.push([`${region} ${industry} ${m}`.trim(), m === '예약' ? 'transactional' : 'local', 0.8]); 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 s of services) { for (const a of audiences) {
raw.push([`${region} ${s}`.trim(), 'local', 0.85]); push(`${region} ${a} ${industry}`, 'local', 0.88);
raw.push([`${s} 가격`, 'transactional', 0.7]); for (const m of MODIFIERS.slice(0, 4)) push(`${region} ${a} ${industry} ${m}`, intentOf(m), 0.74);
raw.push([`${s} 잘하는 곳`, 'informational', 0.65]); push(`${a} ${industry} 추천`, 'informational', 0.62);
} }
for (const f of features) { for (const f of features) {
raw.push([`${industry} ${f}`.trim(), 'informational', 0.6]); 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) {
raw.push([`${region}${industry}추천`, 'local', 0.5]); push(`${region} ${s}`, 'local', 0.82);
raw.push([`${region} ${industry} 추천`, 'local', 0.5]); 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<string>(); const seen = new Set<string>();
const keywords: KeywordCandidate[] = []; const keywords: KeywordCandidate[] = [];
for (const [keyword, intent, relevance] of raw) { for (const [raw, intent, relevance] of out) {
const k = keyword.replace(/\s+/g, ' ').trim(); const k = raw.replace(/\s+/g, ' ').trim();
if (!k) continue; if (!k || seen.has(k)) continue;
if (seen.has(k)) continue;
seen.add(k); seen.add(k);
keywords.push({ keyword: k, intent, relevance, rationale: `mock: ${intent}` }); keywords.push({ keyword: k, intent, relevance, rationale: `mock: ${intent}` });
if (keywords.length >= ctx.targetCount + 4) break; if (keywords.length >= ctx.targetCount) break;
} }
const qaPairs: QaCandidate[] = [ const qaPairs: QaCandidate[] = [
@ -78,12 +108,30 @@ export class MockLlmProvider extends LlmProvider {
? `주요 서비스는 ${services.join(', ')} 입니다.` ? `주요 서비스는 ${services.join(', ')} 입니다.`
: `${industry} 관련 서비스를 제공합니다.`, : `${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 }; 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[] { function toStringArray(v: unknown): string[] {
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} }

View File

@ -37,9 +37,14 @@ export class MerchantsController {
/** 수동 재생성 */ /** 수동 재생성 */
@Post(':id/generate') @Post(':id/generate')
async generate(@Param('id') id: string, @Query('sync') sync?: string) { async generate(
@Param('id') id: string,
@Query('sync') sync?: string,
@Query('count') count?: string,
) {
const target = count ? Math.min(Math.max(1, Number(count)), 500) : undefined;
if (sync === 'true' || sync === '1') { if (sync === 'true' || sync === '1') {
return this.generation.runForMerchant(id, 'manual'); return this.generation.runForMerchant(id, 'manual', target);
} }
const m = await this.merchants.findWithTaxonomy(id); const m = await this.merchants.findWithTaxonomy(id);
const jobId = await this.queue.enqueue(m.id, 'manual'); const jobId = await this.queue.enqueue(m.id, 'manual');

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "scripts"]
}