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