o2o-site-AEO/ontology/src/llm/openai.provider.ts
hbyang 847943a8bc [feat] ontology: 새 숙소 키워드를 OpenAI 로 쌓고 매칭에 쓴다 — 호텔 업종 · 숙소 검색 정정
사전이 고정 데이터셋뿐이라 사전에 없는 숙소는 새 단어를 받지 못했고, 생성분은 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>
2026-10-01 13:49:37 +09:00

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');
}