군산 스테이머뭄 펜션 시드 추가 및 대량 생성 테스트 지원
- 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:
parent
209a381091
commit
81a3407897
@ -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() {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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') : [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "dist", "scripts"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user