diff --git a/client/src/components/chatbot/BotBubbleFrame.tsx b/client/src/components/chatbot/BotBubbleFrame.tsx index 1241525..d6d223d 100644 --- a/client/src/components/chatbot/BotBubbleFrame.tsx +++ b/client/src/components/chatbot/BotBubbleFrame.tsx @@ -169,7 +169,7 @@ const BotBubbleFrame = ({ /> ) : null; case 'showPlanLists': - return args?.plans ? ( + return args?.plans && args.plans.length > 0 ? (
{args.plans.map((plan, index) => ( ))}
- ) : null; + ) : ( + <> + + + ); case 'showFirstCardList': return ; default: diff --git a/client/src/hooks/useChatSocket.ts b/client/src/hooks/useChatSocket.ts index c3e7058..0e6f6af 100644 --- a/client/src/hooks/useChatSocket.ts +++ b/client/src/hooks/useChatSocket.ts @@ -544,49 +544,13 @@ export const useChatSocket = () => { const content = msg.messageChunks.join(''); // 빈 문자열인 메시지는 제외 (function call만 있는 메시지들) if (content.trim() === '') { - const functionName = msg.functionCall?.name; - if ( - functionName === 'showPlanLists' && - msg.functionCall?.args?.plans - ) { - const planNames = msg.functionCall.args.plans - .map((plan: { name: string }) => plan.name) - .join(', '); - return { - role: 'assistant', - content: `${planNames}를 추천받았다`, - }; - } - if ( - functionName === 'requestCarouselButtons' && - msg.functionCall?.args?.items - ) { - const itemLabels = msg.functionCall.args.items - .map((item: { label: string }) => item.label) - .join(', '); - return { - role: 'assistant', - content: `${itemLabels} 선택지를 제공했다`, - }; - } - if (functionName === 'requestOXCarouselButtons') { - return { - role: 'assistant', - content: '예/아니오 선택지를 제공했다', - }; - } - if (functionName === 'requestOTTServiceList') { - return { - role: 'assistant', - content: 'OTT 서비스 선택지를 제공했다', - }; - } + // function call 정보를 간단히 포함 (환각 방지용 중립적 표현) return { - role: 'assistant', - content: `${functionName}을 수행하였음`, + role: 'developer', + content: `사용자 ${msg.functionCall?.name} 기능을 실행했습니다.`, }; } - return { role: 'assistant', content }; + return { role: 'developer', content }; } return null; }) @@ -607,14 +571,6 @@ export const useChatSocket = () => { [sessionId, messages], // 🔧 messages 의존성 추가 ); - // 제거: 서버에 더 이상 선택 상태를 보내지 않음 (로컬스토리지 사용) - // const sendCarouselSelection = useCallback((carouselData, selectedItem, isSelected) => { - // if (!sessionId) return; - // const payload = { sessionId, carouselData, selectedItem, isSelected }; - // console.log('📤 Sending carousel selection:', payload); - // socket.emit('carousel-selection', payload); - // }, [sessionId]); - // 로컬 상태에서만 선택 상태 업데이트 (서버에 보내지 않음) const updateCarouselSelection = useCallback( (messageIndex: number, selectedItem: CarouselItem) => { diff --git a/client/src/pages/ChatTestPage.tsx b/client/src/pages/ChatTestPage.tsx deleted file mode 100644 index 308396c..0000000 --- a/client/src/pages/ChatTestPage.tsx +++ /dev/null @@ -1,288 +0,0 @@ -// import TypingDots from '@/components/chatbot/TypingDots'; -// import LoadingSpinner from '@/components/common/LoadingSpinner'; -// import { useEffect, useRef, useState } from 'react'; -// import { io, Socket } from 'socket.io-client'; - -// interface ChatMessage { -// role: 'user' | 'assistant'; -// content: string; -// } -// interface CarouselItem { -// id: string; -// label: string; -// } -// const socket: Socket = io('http://localhost:3001'); - -// const PlanChatTester = () => { -// const [input, setInput] = useState(''); -// const [chatLog, setChatLog] = useState([]); -// const [isStreaming, setIsStreaming] = useState(false); -// const [optionButtons, setOptionButtons] = useState([]); -// const [sessionId, setSessionId] = useState(null); -// const responseRef = useRef(''); - -// useEffect(() => { -// const existingSessionId = localStorage.getItem('sessionId'); -// socket.emit('init-session', existingSessionId || null); - -// socket.on('session-id', (id: string) => { -// setSessionId(id); -// localStorage.setItem('sessionId', id); -// }); - -// socket.on('session-history', (messages: ChatMessage[]) => { -// setChatLog(messages); -// }); - -// return () => { -// socket.off('session-id'); -// socket.off('session-history'); -// }; -// }, []); -// useEffect(() => { -// // 기존 stream, done, price-options 외 추가 이벤트 처리 - -// socket.on('ott-service-list', ({ question, options }) => { -// setChatLog((prev) => [...prev, { role: 'assistant', content: question }]); -// setOptionButtons(options); -// }); - -// socket.on('carousel-buttons', (items) => { -// setChatLog((prev) => [ -// ...prev, -// { -// role: 'assistant', -// content: '다음 항목 중 하나를 선택해주세요:', -// }, -// ]); -// setOptionButtons(items.map((item: CarouselItem) => item.label)); -// }); - -// socket.on('plan-details', (plan) => { -// const { -// name, -// monthlyFee, -// description, -// dataGb, -// sharedDataGb, -// voiceMinutes, -// bundleBenefit, -// baseBenefit, -// specialBenefit, -// detailUrl, -// } = plan; - -// const formatted = ` -// 📦 ${name} -// 💰 월정액 ${monthlyFee.toLocaleString()}원 - -// 📝 ${description} - -// ━━━━━━━━━━━━━━━━━━ - -// 📶 데이터: ${dataGb === -1 ? '무제한' : `${dataGb}GB`} -// 🔄 공유데이터: ${sharedDataGb} -// 📞 음성통화: ${voiceMinutes} -// 🤝 결합 할인: ${bundleBenefit} -// 🎁 기본 혜택: ${baseBenefit} -// 💎 특별 혜택: ${specialBenefit} - -// 🔗 [요금제 자세히 보기](${detailUrl}) -// `; -// setChatLog((prev) => [ -// ...prev, -// { role: 'assistant', content: formatted }, -// ]); -// }); - -// socket.on('text-buttons', ({ question, options }) => { -// setChatLog((prev) => [...prev, { role: 'assistant', content: question }]); -// setOptionButtons(options); -// }); - -// return () => { -// socket.off('ott-service-list'); -// socket.off('carousel-buttons'); -// socket.off('plan-details'); -// socket.off('text-buttons'); -// }; -// }, []); -// useEffect(() => { -// socket.on('stream', (chunk: string) => { -// responseRef.current += chunk; - -// setChatLog((prev) => { -// const last = prev[prev.length - 1]; -// if (last?.role === 'assistant') { -// return [ -// ...prev.slice(0, -1), -// { role: 'assistant', content: responseRef.current }, -// ]; -// } else { -// return [...prev, { role: 'assistant', content: chunk }]; -// } -// }); -// }); - -// // ✅ 응답 완료 시 스트리밍 상태 해제 -// socket.on('done', () => { -// setIsStreaming(false); -// }); - -// socket.on('disconnect', () => { -// setIsStreaming(false); -// }); -// socket.on('price-options', (options: string[]) => { -// setChatLog((prev) => [ -// ...prev, -// { -// role: 'assistant', -// content: '요금제 추천을 위해 아래 가격대 중 하나를 선택해주세요:', -// }, -// ]); - -// setOptionButtons(options); // 버튼 목록 상태 저장 -// }); -// return () => { -// socket.off('stream'); -// socket.off('done'); // 정리해주기 -// }; -// }, []); - -// const sendPrompt = (text?: string) => { -// const messageToSend = text || input.trim(); -// if (!messageToSend || !sessionId) return; - -// // 🔧 현재 메시지를 추가한 전체 대화 히스토리 생성 -// const newUserMessage = { role: 'user', content: messageToSend }; -// const allMessages = [...chatLog, newUserMessage]; - -// // 🔧 전체 대화 히스토리를 서버로 전송 -// const payload = { -// sessionId, -// message: messageToSend, -// history: allMessages, // 🔧 전체 대화 히스토리 추가 -// }; - -// setChatLog((prev) => [...prev, { role: 'user', content: messageToSend }]); -// setInput(''); -// setIsStreaming(true); -// responseRef.current = ''; -// setOptionButtons([]); - -// socket.emit('chat', payload); -// }; - -// const handleNewChat = () => { -// if (!sessionId) return; -// socket.emit('reset-session', { sessionId }); -// setChatLog([]); -// setInput(''); -// setOptionButtons([]); -// }; - -// return ( -//
-//
-// -// -//
-//

요금제 추천 AI 챗봇

-// -//
-// {chatLog.length === 0 ? ( -//

대화를 시작해보세요!

-// ) : ( -// chatLog.map((msg, idx) => ( -//
-// {msg.role === 'user' ? '나' : 'AI'} -//
-// {msg.content} -//
-//
-// )) -// )} -//
-//
-// setInput(e.target.value)} -// onKeyDown={(e) => { -// if (e.key === 'Enter' && !isStreaming) sendPrompt(); -// }} -// placeholder="질문을 입력하세요..." -// style={{ -// flex: 1, -// padding: '0.5rem', -// borderRadius: '4px', -// border: '1px solid #ccc', -// }} -// disabled={isStreaming} -// /> -// -//
-// {optionButtons.length > 0 && ( -//
-// {optionButtons.map((opt) => ( -// -// ))} -//
-// )} -//
-// ); -// }; - -// export default PlanChatTester; diff --git a/server/app.js b/server/app.js index 5f0cd95..672b725 100644 --- a/server/app.js +++ b/server/app.js @@ -2,7 +2,6 @@ import cors from 'cors'; import dotenv from 'dotenv'; import express from 'express'; import mongoose from 'mongoose'; -import { getInputExamples } from './controllers/chatController.js'; import { getAffordablePlanList, getBundlePlanList, @@ -40,7 +39,6 @@ mongoose const apiRouter = express.Router(); app.use('/api', apiRouter); -apiRouter.get('/chat/inputs', getInputExamples); apiRouter.get('/metadata', getUrlMetadata); apiRouter.get('/plans/:planId', getPlanDetail); apiRouter.get('/unlimited-plans', getUnlimitedDataPlanList); diff --git a/server/cache/planCache.js b/server/cache/planCache.js deleted file mode 100644 index afced6f..0000000 --- a/server/cache/planCache.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Plan } from '../models/Plan.js'; - -let cachedPlans = []; -let lastUpdated = 0; -const TTL = 1000 * 60 * 60; - -export const getPlansWithCache = async () => { - const now = Date.now(); - if (now - lastUpdated > TTL || cachedPlans.length === 0) { - cachedPlans = await Plan.find({}); - lastUpdated = now; - } - return cachedPlans; -}; diff --git a/server/config/db.js b/server/config/db.js deleted file mode 100644 index 91fc62f..0000000 --- a/server/config/db.js +++ /dev/null @@ -1,33 +0,0 @@ -/** 스키마를 유연하게 수정할 때 사용합니다 */ - -import dotenv from 'dotenv'; -import { MongoClient } from 'mongodb'; - -dotenv.config(); - -const client = new MongoClient(process.env.MONGO_URI); - -export const changeSchema = async () => { - const database = client.db('meplus'); - const collection = database.collection('plans'); - const refCollection = database.collection('bundleBenefits'); - - const result = await collection.find({}).toArray(); - - for (const item of result) { - const target = await refCollection.findOne({ _id: item.bundleBenefit }); - - const updated = target - ? { - _id: item.bundleBenefit, - name: target.name, - description: target.description, - } - : null; - - await collection.updateOne( - { _id: item._id }, - { $set: { bundleBenefit: updated } }, - ); - } -}; diff --git a/server/controllers/chatController.js b/server/controllers/chatController.js deleted file mode 100644 index b02804e..0000000 --- a/server/controllers/chatController.js +++ /dev/null @@ -1,32 +0,0 @@ -import { openai } from '../services/gptService.js'; - -/** 채팅 시작: 서술형 답변 예시 가져오기 */ -export const getInputExamples = async (req, res) => { - const getChatResponse = async () => { - const input = [ - { - role: 'system', - content: - '한국어로 요금제 추천 챗봇에게 사용자가 입력할 수 있는 질문 예시를 배열 형태로 3개 생성해줘. 예시는 자연스럽고 실제 사용자 질문처럼 작성하되, 너무 창의적인 표현은 피하고, 단정적이고 실용적인 문장으로 구성해줘. 부연 설명 없이 배열로만 답변해줘.\n답변: [입력 예시1, 입력 예시2, 입력 예시3]', - }, - ]; - - return await openai.responses.create({ - model: 'gpt-4.1-nano', - input, - temperature: 0.5, - max_output_tokens: 256, - top_p: 0.9, - }); - }; - - try { - const chatResponse = await getChatResponse(); - const endIndex = chatResponse.output_text.length - 1; - const inputs = chatResponse.output_text.slice(1, endIndex).split(', '); - - return res.status(200).json({ inputs }); - } catch (error) { - return res.sendStatus(500); - } -}; diff --git a/server/models/ChatSession.js b/server/models/ChatSession.js index 92fca2f..c7d594d 100644 --- a/server/models/ChatSession.js +++ b/server/models/ChatSession.js @@ -4,7 +4,10 @@ const ChatSessionSchema = new mongoose.Schema({ sessionId: String, // 클라이언트에서 받은 고유 ID messages: [ { - role: { type: String, enum: ['user', 'assistant', 'system'] }, + role: { + type: String, + enum: ['user', 'assistant', 'developer', 'system'], + }, content: String, type: { type: String, default: 'text' }, // 추가: 메시지 타입 (text, carousel_select, ox_select 등) data: { type: mongoose.Schema.Types.Mixed, default: null }, // 추가: 선택 데이터 diff --git a/server/services/gptFuncDefinitions.js b/server/services/gptFuncDefinitions.js index 52c4e6a..69d8125 100644 --- a/server/services/gptFuncDefinitions.js +++ b/server/services/gptFuncDefinitions.js @@ -322,8 +322,6 @@ export const searchPlansFromDB = async (searchConditions) => { } } - console.log('📋 생성된 MongoDB 쿼리:', JSON.stringify(query, null, 2)); - // 쿼리 실행 const plans = await Plan.find(query) .select(EXCLUDED_FIELDS) diff --git a/server/services/gptFunctionHandler.js b/server/services/gptFunctionHandler.js index ba2a71c..9d40d59 100644 --- a/server/services/gptFunctionHandler.js +++ b/server/services/gptFunctionHandler.js @@ -31,11 +31,6 @@ const parseFunctionArgs = (functionArgsRaw) => { .replace(/\s+/g, ' ') .trim(); - console.log( - '🔄 변환 시도 (처음 200자):', - fixedJson.substring(0, 200) + '...', - ); - return JSON.parse(fixedJson); } catch (secondParseError) { // eval 방식으로 재시도 @@ -46,7 +41,6 @@ const parseFunctionArgs = (functionArgsRaw) => { console.error('❌ 최종 JSON 파싱 실패:', secondParseError); console.error('❌ eval 방식도 실패:', evalError); console.log('🔍 원본:', functionArgsRaw); - console.log('🔍 변환 시도:', fixedJson); throw new Error('Function arguments 파싱에 실패했습니다.'); } } diff --git a/server/services/gptService.js b/server/services/gptService.js index 5a99ec4..40aca31 100644 --- a/server/services/gptService.js +++ b/server/services/gptService.js @@ -100,7 +100,7 @@ export const streamChat = async ( } // 모든 함수 호출 실행 - console.log(functionCalls); + console.log('2:', functionCalls); const functionResults = []; for (const { functionName, functionArgsRaw } of functionCalls) { const result = await handleFunctionCall( @@ -111,20 +111,20 @@ export const streamChat = async ( // 함수 실행 정보 추가 functionResults.push({ - role: 'assistant', + role: 'developer', content: `${functionName} 함수를 호출했습니다. 인자: ${functionArgsRaw}`, }); if (functionName === 'searchPlans' && result) { if (result.result === 'empty') { functionResults.push({ - role: 'function', + role: 'developer', name: functionName, content: `검색 결과: 빈 배열 (조건에 맞는 요금제 없음)`, }); } else if (result.result === 'found') { functionResults.push({ - role: 'function', + role: 'developer', name: functionName, content: `검색 결과: ${result.plansCount}개 요금제 발견 (${result.planNames?.join(', ')})`, }); @@ -165,10 +165,10 @@ export const streamChatWithFollowUp = async (messages, socket, onDelta) => { if (hasFunctionCalls) { // 역질문 대상 함수들 const followUpTargetFunctions = ['requestTextCard', 'searchPlans']; - console.log(functionResults); + console.log('1:', functionResults); // 실행된 함수들 중 역질문 대상이 있는지 확인 const executedFunctionNames = functionResults - .filter((result) => result.role === 'assistant') + .filter((result) => result.role === 'developer') .map((result) => { const match = result.content.match(/^(\w+) 함수를 호출했습니다/); return match ? match[1] : null; @@ -211,20 +211,32 @@ const generateFollowUpQuestion = async ( // 실행된 함수들 정보 추출 const executedFunctions = functionResults - .filter((result) => result.role === 'assistant') + .filter((result) => result.role === 'developer') .map((result) => result.content) .join('\n'); // requestTextCard가 이미 실행되었는지 확인 const hasTextCardExecuted = functionResults.some( (result) => - result.role === 'assistant' && result.content.includes('requestTextCard'), + result.role === 'developer' && result.content.includes('requestTextCard'), ); + // searchPlans 결과가 빈 배열인지 확인 + const hasEmptySearchResult = functionResults.some( + (result) => + result.role === 'developer' && result.content.includes('빈 배열'), + ); + + console.log( + '여기', + hasTextCardExecuted, + executedFunctions, + hasEmptySearchResult, + ); const followUpMessages = [ { role: 'system', - content: `너는 요금제 추천 후 고객에게 추가 혜택을 안내하는 상담사야. + content: `너는 요금제 추천 후 이어서 고객에게 추가 혜택을 안내하는 상담사야. **ImageCard(requestTextCard) 실행 확인:** ${ @@ -235,66 +247,62 @@ ${ : `- 아직 링크 정보가 제공되지 않았으므로, 아래 패턴에 따라 추가 혜택 질문을 진행해도 됨` } -**검색 결과 확인 우선:** -- 방금 searchPlans 함수가 빈 배열([])을 반환했다면, 조건에 맞는 요금제가 없다는 뜻이야 -- 이 경우 "조건에 맞는 요금제를 찾지 못했어요. 😅 다른 옵션을 확인해보시는 것은 어떨까요?"라고 안내하고 다음 중 하나를 제안해줘: +**searchPlans 검색 결과 확인:** +${hasEmptySearchResult ? '검색 결과가 비어있음 (조건에 맞는 요금제 없음)' : '검색 결과 있음 (요금제 발견됨)'} + +${ + hasEmptySearchResult + ? ` +**검색 결과 없음 - 대안 제시 필수:** +"조건에 맞는 요금제를 찾지 못했어요. 😅 다른 옵션을 확인해보시는 것은 어떨까요?"라고 안내하고 다음 중 하나를 제안해줘: -**검색 결과 없음 시 대안 제시:** 1. "예산을 조금 더 늘려서 찾아볼까요?" → requestCarouselButtons로 더 높은 가격대 옵션 제공 2. "다른 통신 기술(5G/LTE)도 함께 살펴보시겠어요?" → requestOXCarouselButtons 호출 3. "대신 인기 요금제들을 추천해드릴까요?" → requestCarouselButtons로 ["인기 요금제 보기", "조건 다시 설정", "상담원 연결"] 제공 4. "조건을 다시 설정해서 찾아보시겠어요?" → requestCarouselButtons로 새로운 선택지 제공 - -**검색 결과가 있는 경우에만 아래 추가 혜택 질문:** +` + : ` +**검색 결과 있음 - 추가 혜택 질문:` +} 이미 요금제를 보여줬으니, 요금제 설명은 다시 하지 말고 추가 혜택 질문만 해줘: -**중요: 질문 텍스트를 먼저 출력하고 그 다음에 함수 호출** - -**질문 예시들:** -1. "혹시 가족 구성원 중 만 18세 이하의 청소년 자녀가 있으신가요? 있으시다면 추가 결합 혜택도 안내드릴게요!" - → 이 질문 텍스트를 먼저 출력한 후 requestOXCarouselButtons 호출 +**우선순위별 질문 예시들:** +1. **가족 할인 확인 (최우선)**: "혹시 가족 구성원 중 만 18세 이하의 청소년 자녀가 있으신가요? 있으시다면 추가 결합 혜택도 안내드릴게요!" + → requestOXCarouselButtons 호출 -2. "혹시 사용 중인 인터넷이 있으신가요? LG U+에서 500Mbps 이상 인터넷을 사용 중이시면 추가 할인을 받을 수 있어요!" - → 이 질문 텍스트를 먼저 출력한 후 requestOXCarouselButtons 호출 +2. **인터넷 결합 할인**: "혹시 사용 중인 인터넷이 있으신가요? LG U+에서 500Mbps 이상 인터넷을 사용 중이시면 추가 할인을 받을 수 있어요!" + → requestOXCarouselButtons 호출 -3. "평소 한 달에 데이터를 얼마나 사용하시나요? 더 정확한 요금제를 추천드릴게요!" - → 이 질문 텍스트를 먼저 출력한 후 requestCarouselButtons 호출 - -4. "평소 자주 시청하시는 OTT 서비스가 있으신가요? 요금제와 함께 이용하시면 더 저렴해질 수 있어요!" - → 이 질문 텍스트를 먼저 출력한 후 requestOTTServiceList 호출 +3. **데이터 사용량 재확인**: "평소 한 달에 데이터를 얼마나 사용하시나요? 더 정확한 요금제를 추천드릴게요!" + → requestCarouselButtons 호출 + +**중요**: 가족 할인이나 인터넷 결합 할인을 우선적으로 물어보고, OTT 서비스는 꼭 필요한 경우에만 질문해줘. **절대 규칙:** -- 요금제 정보는 절대 다시 설명하지 마 -- **매우 중요**: 반드시 질문 텍스트를 먼저 출력하고 그 다음에 함수 호출해야 함 -- 텍스트 없이 바로 함수만 호출하는 것은 절대 금지 -- "답변해주세요", "알려주세요" 같은 추가 멘트 금지 -- 검색 결과가 없으면 검색 결과 없음 대안 제시가 우선, 결과가 있으면 추가 혜택 질문 +요금제 정보는 절대 다시 설명하지 마 +매우 중요: 반드시 질문 텍스트를 먼저 출력하고 그 다음에 함수 호출해야 함 +텍스트 없이 바로 함수만 호출하는 것은 절대 금지 +검색 결과가 없으면 검색 결과 없음 대안 제시가 우선, 결과가 있으면 추가 혜택 질문 +- 텍스트 없이 바로 requestCarouselButtons 호출 금지 +- 텍스트 없이 바로 requestOXCarouselButtons 호출 금지 +- 텍스트 없이 바로 requestOTTServiceList 호출 금지 **올바른 응답 형식:** -1. 먼저 텍스트로 질문을 출력 (예: "혹시 가족 구성원 중 만 18세 이하의 청소년 자녀가 있으신가요?") -2. 그 다음에 함수 호출 (예: requestOXCarouselButtons) +1.먼저 텍스트로 질문을 출력 (예: "혹시 가족 구성원 중 만 18세 이하의 청소년 자녀가 있으신가요?") +2.그 다음에 함수 호출 (예: requestOXCarouselButtons) -**잘못된 예시 (금지):** -- 텍스트 없이 바로 requestCarouselButtons 호출 -- 텍스트 없이 바로 requestOXCarouselButtons 호출 -- 텍스트 없이 바로 requestOTTServiceList 호출 `, - }, - ...userMessages, - { - role: 'assistant', - content: '요금제를 확인해보세요.', +`, }, { role: 'system', - content: `방금 실행된 함수들: -${executedFunctions} - + content: ` 🚨 중요: 무조건 아래 순서대로 해야 함: 1. 먼저 텍스트로 질문 출력 (예: "혹시 가족분들과 함께 가입하시면 더 저렴해질 수 있는데, 관심 있으신가요?") 2. 그 다음에 함수 호출 (예: requestOXCarouselButtons) 텍스트 없이 바로 함수만 호출하는 것은 절대 금지. 반드시 텍스트 먼저 출력하고 함수 호출.`, }, + ...userMessages, ]; // 역질문 전용 streamChat 호출 (FOLLOWUP_TOOLS 사용) @@ -366,9 +374,6 @@ const streamChatForFollowUp = async (messages, socket, model) => { } } - // 역질문 함수 호출 실행 - console.log('Has text content:', hasTextContent); - for (const { functionName, functionArgsRaw } of functionCalls) { await handleFunctionCall(functionName, functionArgsRaw, socket); } diff --git a/server/socket/socket.js b/server/socket/socket.js index f7833bc..39e98c4 100644 --- a/server/socket/socket.js +++ b/server/socket/socket.js @@ -26,32 +26,6 @@ export const setupSocket = (server) => { handlePlanRecommend(socket, userInput); }); - /** 가이드 별 적절한 요금제를 추천 */ - socket.on('recommend-plan-by-guide', async (message) => { - console.log('recommend-plan-by-guide >>', message); - const input = [ - { - role: InputRoleEnum.SYSTEM, - content: - '너는 사용자의 조건에 맞는 휴대폰 요금제를 추천하는 전문가 챗봇이야. 사용자가 요금제 조건을 입력하면, 반드시 한 번 조건에 맞는 함수를 호출하여 데이터를 기반으로 요금제를 추천해야 해.\n\n요금제를 추천하는 이유는 간결하고 명확하게 설명해줘. 설명은 3줄 이내로 요약하고, 사용자의 조건(예: 데이터 용량, 가격, 연령대, 결합 혜택 등)과 관련된 요점만 언급해줘. 추천 이유만 말해야 해.', - }, - { - role: InputRoleEnum.USER, - content: `조건: ${conditionByPlanGuide[message.guide]}`, - }, - ]; - - const planInput = await emitRecommendReasonByGuide(input, socket); - const systemInput = { - role: InputRoleEnum.SYSTEM, - content: - '너는 사용자의 조건에 맞는 휴대폰 요금제를 추천하는 전문가 챗봇이야. 주어진 조건과 추천 이유, 요금제 데이터를 보고 추천하는 요금제의 ID 목록을 최대 3가지 출력해줘. ID는 실제 데이터에 있는 _id를 사용해야 해. 응답은 반드시 배열로 출력해야 하고 다른 문장은 출력하면 안돼.', - }; - console.log([systemInput, ...planInput.slice(1)]); - const ids = await getPlanIds([systemInput, ...planInput.slice(1)]); - socket.emit('recommend-plan-by-guide', { plans: ids }); - }); - socket.on('disconnect', () => { console.log('❌ User disconnected:', socket.id); }); diff --git a/server/utils/constants.js b/server/utils/constants.js index bedeece..4e5a228 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -63,8 +63,10 @@ export const conditionByPlanGuide = { export const InputRoleEnum = { USER: 'user', - SYSTEM: 'system', + DEVELOPER: 'developer', ASSISTANT: 'assistant', + PLATFORM: 'platform', + SYSTEM: 'system', }; export const GPTConfig = { diff --git a/server/utils/promptBuilder.js b/server/utils/promptBuilder.js index c512870..df05d68 100644 --- a/server/utils/promptBuilder.js +++ b/server/utils/promptBuilder.js @@ -1,21 +1,27 @@ export const buildPromptMessages = (fullMessages) => { const systemMessage = { role: 'system', - content: `너는 LG유플러스 요금제 추천 도우미야! 반드시 간단한 인사와 요금제 추천과 관련된 질문에만 응답해야 해. 요금제 외의 질문(예: 요리 레시피, 날씨, 일반 상식 등)은 답변하지 말고 "저는 요금제 추천 도우미입니다. 📱💡" 라면서 요금제 추천에 관심이 있냐고 유저에게 친절하게 안내해. + content: `절대 금지 사항 (최우선) +1. 구체적인 선택지를 텍스트로 나열하지 마라! (예: "3-5만원, 5-7만원, 7-10만원...") +2. 이전에 assistant로 응답받은 내용을 다시 말하지 마라! +3. 어떤 버튼으로 호출한다고 알려주지마 -**Function Calling 활용 가이드** +너는 LG유플러스 요금제 추천 도우미야! 반드시 간단한 인사와 요금제 추천과 관련된 질문에만 응답해야 해. 요금제 외의 질문(예: 요리 레시피, 날씨, 일반 상식 등)은 답변하지 말고 "저는 요금제 추천 도우미입니다. 📱💡" 라면서 요금제 추천에 관심이 있냐고 유저에게 친절하게 안내해. + +Function Calling 아래 5가지 함수들을 적절한 상황에 맞춰 호출해야 해. 이 함수들은 유저가 일일이 타이핑하는 수고를 덜어주고 빠른 선택을 도와주기 위한 것이야! - **함수 호출 우선 원칙**: 직접 응답보다는 함수 호출을 통해 사용자 경험을 향상시켜야 해. + 함수 호출 우선 원칙: 직접 응답보다는 함수 호출을 통해 사용자 경험을 향상시켜야 해. - **Function Calling 목록** (총 6개): + Function Calling 목록 (총 6개): -1. **searchPlans**: 사용자가 요구하는 조건에 맞는 요금제를 MongoDB에서 검색해서 추천할 때 사용해. 카테고리(5G/LTE), 최대월요금, 최소데이터량, 연령대, 인기여부 등의 조건을 설정할 수 있고, 자동으로 최대 3개까지 추천해줘. +1. searchPlans: 사용자가 요구하는 조건에 맞는 요금제를 MongoDB에서 검색해서 추천할 때 사용해. 카테고리(5G/LTE), 최대월요금, 최소데이터량, 연령대, 인기여부 등의 조건을 설정할 수 있고, 자동으로 최대 3개까지 추천해줘. **중요**: 사용자로부터 **충분한 정보를 수집한 후에만** 호출해야 함! 최소한 다음 중 2-3개는 파악해야 함: - 선호하는 통신 기술 (5G/LTE) - 예산 범위 (월 요금) - 데이터 사용량 (무제한/일정 GB) - 연령대나 특별한 조건 (청소년, 학생, 시니어 등) +- 부가서비스 종류 2. **requestOTTServiceList**: 유저에게 OTT 서비스(넷플릭스, 디즈니+, 티빙 등) 중 어떤 것을 사용 중인지 버튼으로 물어봐야 할 때 사용해. **중요**: 반드시 질문 텍스트를 먼저 출력한 후 함수 호출! @@ -29,8 +35,6 @@ export const buildPromptMessages = (fullMessages) => { **중요**: 반드시 캐러셀 버튼을 보내기 전에 안내 텍스트를 먼저 출력해야 함! 예시: "어떤 데이터량이 필요하신가요? 📊" (텍스트 먼저) → 그 다음 requestCarouselButtons 호출 -5. **showPlanLists**: [사용하지 않음] 이 함수는 더 이상 직접 호출하지 않습니다. searchPlans 함수가 자동으로 처리합니다. - 6. **requestTextCard**: 유저에게 특정 웹사이트나 링크로 안내할 때 사용해. URL의 미리보기 이미지와 함께 카드 형태로 보여줘. 유플러스 사이트나 추천하는 외부 링크를 안내할 때 사용해. (예: "자세한 내용은 공식 사이트에서 확인하세요", "더 많은 혜택 정보 보기" 등) **요금제 추천 시 응답 패턴**: @@ -38,9 +42,12 @@ export const buildPromptMessages = (fullMessages) => { **1단계: 정보 수집 우선** 사용자가 막연하게 "요금제 추천해줘"라고 하면, 바로 searchPlans를 호출하지 말고 **필수 정보를 먼저 수집**해야 함: +- 고용량 데이터 요금제를 추천해주세요라는 질문이 오면, + "**고용량 데이터 요금제를 찾으시는군요!** 고용량 요금제는 데이터 걱정없이 마음 껏 쓸 수 있는 요금제에요😀 \n \n요금제를 추천해 드리기 위해, 고객님의 한달 데이터 사용량은 얼마인가요?" → requestCarouselButtons(["50GB 이하", "120GB 이하", "200GB 이하", "무제한 필요", "모르겠음"]) - "어떤 통신 기술을 선호하시나요?" → requestCarouselButtons(["5G", "LTE", "상관없음"]) - "월 예산은 어느 정도로 생각하고 계신가요?" → requestCarouselButtons(["3-5만원", "5-7만원", "7-10만원", "10-15만원", "15만원 이상", "예산 무관"]) -- "평소 데이터를 얼마나 사용하시나요?" → requestCarouselButtons(["20GB 이하", "50GB 정도", "100GB 이상", "무제한 필요"]) +- 일반적으로 유저가 요금제를 추천해달라고하면, +"한달에 데이터를 얼마나 사용하시나요?" → requestCarouselButtons(["5GB 이하", "15GB 이하", "50GB 이하", "무제한 필요", "모르겠음"]) 실행 **2단계: 충분한 정보 확보 후 추천** 위 정보 중 2-3개를 파악한 후에만 다음과 같이 응답: @@ -55,24 +62,6 @@ export const buildPromptMessages = (fullMessages) => { 사용자: "요금제 추천해줘" → AI: 바로 searchPlans 호출 (❌) -**✅ 올바른 패턴 (정보 수집 후 추천):** -사용자: "요금제 추천해줘" -→ AI: "안녕하세요! 😊 맞춤 요금제를 추천해드리기 위해 몇 가지 여쭤볼게요. - -먼저 어떤 통신 기술을 선호하시나요?" -→ requestCarouselButtons(["5G", "LTE", "상관없음"]) - -사용자: "5G" -→ AI: "5G 선택해주셨네요! 👍 그럼 월 예산은 어느 정도로 생각하고 계신가요?" -→ requestCarouselButtons(["3-5만원", "5-7만원", "7-10만원", "10-15만원", "15만원 이상", "예산 무관"]) - -사용자: "7-10만원" -→ AI: "좋습니다! 마지막으로 평소 데이터를 얼마나 사용하시나요?" -→ requestCarouselButtons(["20GB 이하", "50GB 정도", "100GB 이상", "무제한 필요"]) - -사용자: "무제한 필요" -→ AI: "완벽해요! 5G 무제한 요금제 중 7-10만원대로 추천드릴게요! 😊" -→ **이제 searchPlans({ category: "5G", minMonthlyFee: 70000, maxMonthlyFee: 100000, minDataGb: -1 }) 호출** **searchPlans 사용 시 주의사항:** - 사용자의 요구사항에 맞는 조건을 정확히 설정해야 해 @@ -84,37 +73,26 @@ export const buildPromptMessages = (fullMessages) => { - "월 예산은 어느 정도로 생각하고 계신가요?" → requestCarouselButtons 호출 - 버튼 옵션: ["3-5만원", "5-7만원", "7-10만원", "10-15만원", "15만원 이상", "예산 무관"] - 사용자 선택에 따른 정확한 금액 설정: - - "3-5만원" → minMonthlyFee: 30000, maxMonthlyFee: 50000 - - "5-7만원" → minMonthlyFee: 50000, maxMonthlyFee: 70000 - - "7-10만원" → minMonthlyFee: 70000, maxMonthlyFee: 100000 - - "10-15만원" → minMonthlyFee: 100000, maxMonthlyFee: 150000 - - "15만원 이상" → minMonthlyFee: 150000 + - "3-5만원" → 최소요금: 30000, 최대요금: 50000 + - "5-7만원" → 최소요금: 50000, 최대요금: 70000 + - "7-10만원" → 최소요금: 70000, 최대요금: 100000 + - "10-15만원" → 최소요금: 100000, 최대요금: 150000 + - "15만원 이상" → 최쇼요금: 150000 - "예산 무관" → 금액 조건 생략 - minDataGb: 최소 데이터량 (-1은 무제한, 숫자로 입력) - ageGroup: "YOUTH", "SENIOR", "STUDENT", "SOLDIER", "ALL" 중 선택 - isPopular: true/false (인기 요금제만 필터링할지 여부) -- **preferredAddons**: 선호하는 부가서비스 (예: ["NETFLIX", "DISNEY", "TVING", "MUSIC"]) +- preferredAddons: 선호하는 부가서비스 (예: ["NETFLIX", "DISNEY", "TVING", "MUSIC"]) - 사용자가 OTT 서비스나 음악 서비스를 언급하면 해당 키워드 포함 - 사용 가능한 키워드: "NETFLIX", "DISNEY", "TVING", "MUSIC", "YOUTUBE", "BOOK", "KIDS", "UPLAY", "MEDIA", "PREMIUM" - 실제 데이터 매칭: 넷플릭스, 디즈니+, 티빙, 바이브/지니뮤직, 유튜브 프리미엄, 밀리의 서재, 아이들나라, 유플레이 등 - limit: 조회할 개수 (기본값 3개, 최대 3개 권장) -또는 상황에 따라 requestCarouselButtons, requestOXCarouselButtons, requestTextCard 함수로 선택지를 먼저 유도할 수도 있음. - 항상 친절하고 자연스럽게 응답한 후, 적절한 함수로 연결되도록 한다. ...예를 들어 '50,000원 이하 요금제 알려줘요'처럼 구체적인 사용 상황이 빠졌다면, '데이터 사용량은 얼마나 되시나요?' 같은 질문을 먼저 해도 좋아. -**역질문 패턴 (요금제 추천 후 필수 실행)**: -searchPlans 함수를 호출한 후에는 반드시 아래 중 하나 이상의 역질문을 통해 사용자 경험을 개선해야 해: - -**역질문 우선순위**: -0. **검색 결과 없음**: searchPlans 함수 호출 후 빈 배열([])이 반환되면, "조건에 맞는 요금제를 찾지 못했어요. 😅 검색 조건을 조금 완화해서 다른 요금제들을 살펴보시는 것은 어떨까요?"라고 안내한 후, 다음 중 하나를 제안해야 함: - - 예산 범위 확대: "예산을 조금 더 늘려서 찾아볼까요?" → requestCarouselButtons로 더 높은 가격대 옵션 제공 - - 다른 통신기술 제안: "LTE 요금제도 함께 살펴보시겠어요?" → requestOXCarouselButtons 호출 - - 인기 요금제 대안: "대신 인기 요금제들을 추천해드릴까요?" → searchPlans({ isPopular: true, limit: 3 }) 호출 - - 조건 재설정: "다른 조건으로 다시 찾아보시겠어요?" → requestCarouselButtons로 새로운 선택지 제공 - +사용자 정보가면 추가로 더 물어볼 수도있어 1. **가족 결합 할인**: "가족분들과 함께 가입하시면 더 저렴하게 이용하실 수 있어요! 가족 결합 할인에 관심 있으신가요?" → requestOXCarouselButtons 호출 - 사용자가 "예" 선택 시: "U+ 투게더 결합에 대해 더 자세히 알아보시겠어요?" → requestTextCard 호출 @@ -130,18 +108,6 @@ searchPlans 함수를 호출한 후에는 반드시 아래 중 하나 이상의 - url: "https://www.lguplus.com/mobile/plan" - buttonText: "공식 홈페이지 방문" -**역질문 실행 규칙**: -- searchPlans 후 반드시 1개의 역질문을 실행해야 함 -- **최우선**: 검색 결과가 빈 배열([])이면 반드시 "검색 결과 없음" 패턴 실행 -- 검색 결과가 있는 경우: 사용자가 이미 언급한 내용(예: 가족 언급 시 가족결합)을 우선 선택 -- 언급하지 않은 경우 가족결합 → OTT → 부가서비스 순으로 진행 - -**사용자 관심 표현 시 추가 안내 (requestTextCard 활용)**: -- **가족결합 할인 관심 표현 시**: "예" 선택하면 → "U+ 투게더 결합에 대해 더 자세히 알아보시겠어요?" → requestTextCard로 U+ 투게더 결합 공식 페이지 안내 - - title: "U+ 투게더 결합 할인 안내" - - description: "가족, 친구와 함께 가입하면 최대 20,000원까지 할인! 청소년 추가 할인 혜택도 확인해보세요." - - url: "https://www.lguplus.com/mobile/combined/together" - - buttonText: "자세히 보기" - **5G 시그니처 가족할인 안내**: 5G 시그니처 요금제 추천 시 → "자녀가 있으신 분들께는 5G 시그니처 가족할인도 있어요. 자세한 내용을 확인해보시겠어요?" → requestTextCard 호출 - title: "5G 시그니처 가족할인" @@ -155,12 +121,6 @@ searchPlans 함수를 호출한 후에는 반드시 아래 중 하나 이상의 - url: "https://www.lguplus.com/mobile/plan/addon" - buttonText: "부가서비스 보기" -- **부가서비스 관심 표현 시**: "추가 서비스가 필요하시다면 유플러스 공식 부가서비스 페이지에서 다양한 옵션을 확인해보세요!" → requestTextCard 호출 - - title: "LG U+ 부가서비스 전체 보기" - - description: "음악, 영상, 보안, 생활편의 등 다양한 부가서비스를 한눈에 확인하고 선택하세요." - - url: "https://www.lguplus.com/mobile/plan/addon" - - buttonText: "부가서비스 둘러보기" - **예시**: "위의 요금제들 어떠신가요? 😊 @@ -176,43 +136,22 @@ searchPlans 함수를 호출한 후에는 반드시 아래 중 하나 이상의 **캐러셀 버튼 사용 시 필수 규칙**: - **모든 캐러셀 버튼 함수(requestCarouselButtons, requestOXCarouselButtons, requestOTTServiceList) 호출 전에 반드시 질문이나 안내 텍스트를 먼저 출력해야 함** - 텍스트 출력 후 즉시 함수 호출 -- **올바른 예시들:** +- 올바른 예시들: • "어떤 요금대를 원하시나요? 💰" → requestCarouselButtons(요금대 옵션들) • "평소 데이터를 얼마나 사용하시나요? 📱" → requestCarouselButtons(데이터량 옵션들) • "가족 결합 할인에 관심 있으신가요? 👨‍👩‍👧‍👦" → requestOXCarouselButtons 호출 • "어떤 OTT 서비스를 함께 사용 중이신가요? 🎬" → requestOTTServiceList 호출 -- **잘못된 예시:** 텍스트 없이 바로 함수 호출 ❌ -**매우 중요한 규칙**: +매우 중요한 규칙: - 절대로 "functions.함수명(...)" 같은 코드를 텍스트로 응답하지 마! - 사용자에게 함수 호출 코드를 보여주는 것은 금지! - 반드시 실제 tool_call 기능만 사용해! - 만약 버튼이나 선택지를 보여주고 싶다면, 텍스트 설명 후 바로 해당 도구를 호출해! -- searchPlans 호출 후에는 반드시 역질문 패턴을 실행해야 함! - -**searchPlans 함수 사용 예시**: - -사용자: "5G 요금제 중에서 8만원 이하로 추천해줘" (명확한 예산 제한) -→ searchPlans({ category: "5G", maxMonthlyFee: 80000, limit: 3 }) - -사용자가 캐러셀 버튼에서 "5-7만원" 선택 -→ searchPlans({ category: "5G", minMonthlyFee: 50000, maxMonthlyFee: 70000, limit: 3 }) - -사용자가 캐러셀 버튼에서 "10-15만원" 선택 + 넷플릭스 언급 -→ searchPlans({ minMonthlyFee: 100000, maxMonthlyFee: 150000, preferredAddons: ["NETFLIX"], limit: 3 }) - -사용자가 캐러셀 버튼에서 "15만원 이상" 선택 + 5G 음악 서비스 -→ searchPlans({ category: "5G", minMonthlyFee: 150000, preferredAddons: ["MUSIC"], limit: 3 }) - -사용자가 캐러셀 버튼에서 "예산 무관" 선택 + 5G 넷플릭스 -→ searchPlans({ category: "5G", preferredAddons: ["NETFLIX"], limit: 3 }) - -사용자: "청년 대상 무제한 데이터 요금제 알려줘" (예산 미언급 - 질문 필요) -→ 먼저 예산 범위 캐러셀 버튼으로 질문 후 검색 +- requestCarouselButtons 호출할때 반드시 stream으로 설명 해주고 호출. 해당 함수만 단독사용은 금지 -**중요**: 더 이상 요금제 목록을 프롬프트에 포함하지 않습니다. searchPlans 함수가 MongoDB에서 실시간으로 조회합니다. +중요: 더 이상 요금제 목록을 프롬프트에 포함하지 않습니다. searchPlans 함수가 MongoDB에서 실시간으로 조회합니다. -**참고자료 (결합 혜택 설명):** +참고자료 (결합 혜택 설명): - U+ 투게더 결합: U+휴대폰을 쓰는 친구, 가족과 결합하면 데이터 무제한 요금제를 최대 20,000원(4-5인 결합 시) 저렴하게 이용할 수 있어요. 만 18세 이하 청소년은 매달 10,000원 더 할인 받을 수 있어요. (링크:https://www.lguplus.com/mobile/combined/together) - U+투게더 청소년 할인: 휴대폰을 2개 이상 결합할 때 만 18세 이하 청소년이 포함되어 있다면 청소년 한 명당 월 10,000원 추가 할인 - 할인 기간: 가입한 날부터 만 20세가 되는 날까지 (링크:https://www.lguplus.com/mobile/combined/together) - 5G 시그니처 가족할인: 5G 시그니처 요금제 가입 고객의 만 18세 이하 자녀 휴대폰 1대 요금을 최대 33,000원 할인해주는 혜택 - 할인 기간: 신청일 부터 자녀가 만 20세가 되는 날까지 (링크:https://www.lguplus.com/mobile/plan/mplan/5g-all/5g-unlimited/Z202205253)