사전이 고정 데이터셋뿐이라 사전에 없는 숙소는 새 단어를 받지 못했고, 생성분은 MATCH_SOURCES 가 llm 을 빼서 만들어도 추천에 안 나왔다. 숙소 검색은 가까운 키워드를 사전 전체(7천)에서 뽑아 상위가 아무 숙소에도 안 붙은 단어로 차서 `선유도 독채` 가 0건이었다. - merchants.controller: 처음 보는 업체·기한 지난 업체만 생성, sync 는 처음 보는 업체만 기다림 — 재빌드마다 불리는 창구라 조건이 없으면 저장마다 요금. 동기 생성 실패는 200 + generation.failed - generation·dedup·keyword.repository: mock 생성분은 source='mock' — compose 기본이 mock 이라 키 없는 환경의 가짜 단어가 매칭 후보가 되지 않게 - match.service: llm 출처를 후보에 넣음(brand 는 그 업체 것만), 업종은 뿌리(stay)로 좁힘 — 새 호텔은 호텔 사전이 비어 잎으로 좁히면 후보 0건 - openai.provider: 미확인 시설·다른 숙박 유형어·가격 주장 금지 - drizzle/0002 · seed: stay.hotel — 시드만 두면 떠 있는 DB 에서 외래키 위반 500 - serving.service: 숙소에 연결된 키워드만 비교, 검색어가 지역을 말하면 그 지역 숙소만 — e5-small 은 지역이 틀려도 0.85(`여수 호텔` → 군산 호텔 0.848)라 임계값으로는 못 가른다 tsc 통과. 로컬 :3101 + 실 OpenAI(가짜 숙소 2곳): 첫 발행 7초 · 키워드 12 생성 · 재발행 fresh, 검색 7건(`은파호수공원 근처 호텔`·`선유도 독채`·`군산시내호텔` 적중, `여수 호텔` 0건) 확인 후 테스트 데이터 삭제 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
119 lines
4.4 KiB
TypeScript
119 lines
4.4 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import OpenAI from 'openai';
|
|
import { env } from '../config/env';
|
|
import { GenerationOutput, LlmProvider, MerchantContext } from './types';
|
|
|
|
/** Structured Outputs 로 강제하는 응답 스키마 — 자유 텍스트 파싱 금지 */
|
|
const RESPONSE_SCHEMA = {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['keywords', 'qa_pairs'],
|
|
properties: {
|
|
keywords: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['keyword', 'intent', 'relevance', 'rationale'],
|
|
properties: {
|
|
keyword: { type: 'string' },
|
|
intent: {
|
|
type: 'string',
|
|
enum: ['informational', 'navigational', 'transactional', 'local', 'brand'],
|
|
},
|
|
relevance: { type: 'number' },
|
|
rationale: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
qa_pairs: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['question', 'answer'],
|
|
properties: {
|
|
question: { type: 'string' },
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as const;
|
|
|
|
@Injectable()
|
|
export class OpenAiLlmProvider extends LlmProvider {
|
|
readonly name = 'openai';
|
|
readonly model = env.llm.model;
|
|
private readonly logger = new Logger(OpenAiLlmProvider.name);
|
|
private readonly client = new OpenAI({ apiKey: env.llm.apiKey });
|
|
|
|
|
|
async generate(ctx: MerchantContext): Promise<GenerationOutput> {
|
|
const res = await this.client.chat.completions.create({
|
|
model: this.model,
|
|
temperature: 0.7,
|
|
messages: [
|
|
{ role: 'system', content: SYSTEM_PROMPT },
|
|
{ role: 'user', content: buildUserPrompt(ctx) },
|
|
],
|
|
response_format: {
|
|
type: 'json_schema',
|
|
json_schema: { name: 'seo_aeo_keywords', strict: true, schema: RESPONSE_SCHEMA as any },
|
|
},
|
|
});
|
|
|
|
const content = res.choices[0]?.message?.content ?? '{}';
|
|
const parsed = JSON.parse(content) as {
|
|
keywords?: GenerationOutput['keywords'];
|
|
qa_pairs?: GenerationOutput['qaPairs'];
|
|
};
|
|
|
|
return {
|
|
keywords: parsed.keywords ?? [],
|
|
qaPairs: parsed.qa_pairs ?? [],
|
|
model: this.model,
|
|
provider: this.name,
|
|
usage: {
|
|
prompt_tokens: res.usage?.prompt_tokens ?? 0,
|
|
completion_tokens: res.usage?.completion_tokens ?? 0,
|
|
total_tokens: res.usage?.total_tokens ?? 0,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
const SYSTEM_PROMPT = `당신은 한국 로컬 비즈니스 SEO/AEO 전문가입니다.
|
|
주어진 업체 정보를 바탕으로 검색 유입에 실제로 도움이 되는 키워드와,
|
|
답변엔진(AI 검색)이 인용하기 좋은 질문-답변 쌍을 생성합니다.
|
|
|
|
규칙:
|
|
- 실제 사용자가 검색창에 입력할 법한 자연스러운 한국어 표현만 사용합니다.
|
|
- 지역명 + 업종 + 의도어 조합을 적극 활용합니다.
|
|
- 과장광고 표현(최고, 1위, 100%, 무조건, 완치 등)은 절대 사용하지 않습니다.
|
|
- 제공된 "이미 보유한 키워드"와 의미가 겹치는 것은 생성하지 않습니다.
|
|
- 시설·전망·객실 유형은 "상세"의 features·nearby 에 있는 것만 씁니다. 없는 것을 지어내지 않습니다.
|
|
- "상세"의 unverified 에 있는 항목은 확인되지 않은 것이므로 키워드와 답변 어디에도 쓰지 않습니다.
|
|
- 숙박 유형어는 주어진 업종의 것만 씁니다. 업종이 호텔이면 '펜션'을, 펜션이면 '호텔'을 붙이지 않습니다.
|
|
- 가격·최저가·할인 같은 가격 주장은 쓰지 않습니다.
|
|
- relevance 는 해당 업체와의 관련도를 0.0~1.0 으로 매깁니다.
|
|
- 답변(answer)은 2~3문장, 업체 정보에 근거한 사실만 씁니다.`;
|
|
|
|
function buildUserPrompt(ctx: MerchantContext): string {
|
|
return [
|
|
`# 업체 정보`,
|
|
`- 상호: ${ctx.name}`,
|
|
`- 업종: ${ctx.industryName ?? '미상'} (${ctx.industryPath ?? '-'})`,
|
|
`- 지역: ${ctx.regionName ?? '미상'} (${ctx.regionPath ?? '-'})`,
|
|
`- 소개: ${ctx.description || '없음'}`,
|
|
`- 상세: ${JSON.stringify(ctx.profile, null, 2)}`,
|
|
``,
|
|
`# 이미 보유한 키워드 (이것들과 겹치지 않는 새 후보만 생성)`,
|
|
ctx.existingKeywords.length ? ctx.existingKeywords.map((k) => `- ${k}`).join('\n') : '- (없음)',
|
|
``,
|
|
`# 요청`,
|
|
`- 키워드 ${ctx.targetCount}개`,
|
|
`- 질문-답변 쌍 5개`,
|
|
].join('\n');
|
|
}
|