+ >
);
};
diff --git a/client/src/pages/TestWaitingPage.tsx b/client/src/pages/TestWaitingPage.tsx
index adcb1d6..29b549c 100644
--- a/client/src/pages/TestWaitingPage.tsx
+++ b/client/src/pages/TestWaitingPage.tsx
@@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import moonerImage from '../assets/image/mooner_hmm.png';
+import FloatingIcon from '@/components/common/FloatingIcon';
const TestWaitingPage: React.FC = () => {
const navigate = useNavigate();
@@ -17,19 +18,26 @@ const TestWaitingPage: React.FC = () => {
return (
<>
+ {/* 전체 배경 */}
-

-
- 잠시만 기다려주세요.
-
-
-
- 고객님에게 어울리는 요금제를 찾고있습니다.
-
- 통신 상황에 따라 최대 1분 정도의 시간이 소요될 수 있습니다.
-
+ {/* 가운데 콘텐츠 */}
+
+
+
+
+ 잠시만 기다려주세요.
+
+
+ 고객님에게 어울리는 요금제를 찾고있습니다.
+
+ 통신 상황에 따라 최대 1분 정도의 시간이 소요될 수 있습니다.
+
+
>
);
diff --git a/client/src/utils/chatStorage.ts b/client/src/utils/chatStorage.ts
index e4409c3..685bfac 100644
--- a/client/src/utils/chatStorage.ts
+++ b/client/src/utils/chatStorage.ts
@@ -4,6 +4,23 @@ import type {
FunctionCall,
} from '@/components/chatbot/BotBubbleFrame';
+// UserProfile 타입 정의 (ChatbotPage와 동일)
+export interface UserProfile {
+ plan: {
+ id: string;
+ name: string;
+ monthlyFee: number;
+ benefits: string[];
+ };
+ usage: {
+ call: number;
+ message: number;
+ data: number;
+ };
+ preferences: string[];
+ source: 'plan-test' | 'url-params';
+}
+
export interface StoredMessage {
type: 'user' | 'bot' | 'loading';
text?: string;
@@ -22,6 +39,7 @@ export interface StoredMessage {
export interface ChatSession {
sessionId: string;
messages: StoredMessage[];
+ userProfile?: UserProfile; // URL 파라미터로 온 사용자의 정보
lastUpdated: number;
}
@@ -69,7 +87,6 @@ export const saveSession = (session: ChatSession): void => {
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
- console.log('✅ 세션 저장 완료:', session.sessionId);
} catch (error) {
console.error('❌ 세션 저장 실패:', error);
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c8947ce..b7220f5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -41,6 +41,9 @@ importers:
react:
specifier: ^19.1.0
version: 19.1.0
+ react-confetti:
+ specifier: ^6.4.0
+ version: 6.4.0(react@19.1.0)
react-dom:
specifier: ^19.1.0
version: 19.1.0(react@19.1.0)
@@ -56,6 +59,9 @@ importers:
react-spring-bottom-sheet:
specifier: 3.5.0-alpha.0
version: 3.5.0-alpha.0(@react-three/fiber@9.1.2(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react-native@0.80.0(@babel/core@7.27.4)(@types/react@19.1.6)(react@19.1.0))(react@19.1.0)(three@0.177.0))(@types/react@19.1.6)(konva@9.3.20)(react-dom@19.1.0(react@19.1.0))(react-native@0.80.0(@babel/core@7.27.4)(@types/react@19.1.6)(react@19.1.0))(react-zdog@1.2.2)(react@19.1.0)(three@0.177.0)(zdog@1.1.3)
+ react-use:
+ specifier: ^17.6.0
+ version: 17.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
socket.io-client:
specifier: ^4.8.1
version: 4.8.1
@@ -1113,6 +1119,9 @@ packages:
'@types/istanbul-reports@3.0.4':
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
+ '@types/js-cookie@2.2.7':
+ resolution: {integrity: sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -1221,6 +1230,9 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0
+ '@xobotyi/scrollbar-width@1.9.5':
+ resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==}
+
'@xstate/react@1.6.3':
resolution: {integrity: sha512-NCUReRHPGvvCvj2yLZUTfR0qVp6+apc8G83oXSjN4rl89ZjyujiKrTff55bze/HrsvCsP/sUJASf2n0nzMF1KQ==}
peerDependencies:
@@ -1509,6 +1521,9 @@ packages:
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
engines: {node: '>=18'}
+ copy-to-clipboard@3.3.3:
+ resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
+
cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
@@ -1530,9 +1545,16 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ css-in-js-utils@3.1.0:
+ resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==}
+
css-select@5.1.0:
resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==}
+ css-tree@1.1.3:
+ resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==}
+ engines: {node: '>=8.0.0'}
+
css-what@6.1.0:
resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==}
engines: {node: '>= 6'}
@@ -1806,6 +1828,12 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+ fast-shallow-equal@1.0.0:
+ resolution: {integrity: sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==}
+
+ fastest-stable-stringify@2.0.2:
+ resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==}
+
fastq@1.19.1:
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
@@ -1998,6 +2026,9 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
+ hyphenate-style-name@1.1.0:
+ resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==}
+
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@@ -2040,6 +2071,9 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+ inline-style-prefixer@7.0.1:
+ resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==}
+
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
@@ -2142,6 +2176,9 @@ packages:
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
hasBin: true
+ js-cookie@2.2.1:
+ resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2309,6 +2346,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ mdn-data@2.0.14:
+ resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
+
media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
@@ -2519,6 +2559,12 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ nano-css@5.6.2:
+ resolution: {integrity: sha512-+6bHaC8dSDGALM1HJjOHVXpuastdu2xFoZlC77Jh4cg+33Zcgm+Gxd+1xsnpZK14eyHObSp82+ll5y3SX75liw==}
+ peerDependencies:
+ react: '*'
+ react-dom: '*'
+
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -2741,6 +2787,12 @@ packages:
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
engines: {node: '>= 0.8'}
+ react-confetti@6.4.0:
+ resolution: {integrity: sha512-5MdGUcqxrTU26I2EU7ltkWPwxvucQTuqMm8dUz72z2YMqTD6s9vMcDUysk7n9jnC+lXuCPeJJ7Knf98VEYE9Rg==}
+ engines: {node: '>=16'}
+ peerDependencies:
+ react: ^16.3.0 || ^17.0.1 || ^18.0.0 || ^19.0.0
+
react-devtools-core@6.1.2:
resolution: {integrity: sha512-ldFwzufLletzCikNJVYaxlxMLu7swJ3T2VrGfzXlMsVhZhPDKXA38DEROidaYZVgMAmQnIjymrmqto5pyfrwPA==}
@@ -2830,6 +2882,12 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
+ react-universal-interface@0.6.2:
+ resolution: {integrity: sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==}
+ peerDependencies:
+ react: '*'
+ tslib: '*'
+
react-use-gesture@8.0.1:
resolution: {integrity: sha512-CXzUNkulUdgouaAlvAsC5ZVo0fi9KGSBSk81WrE4kOIcJccpANe9zZkAYr5YZZhqpicIFxitsrGVS4wmoMun9A==}
deprecated: This package is no longer maintained. Please use @use-gesture/react instead
@@ -2845,6 +2903,12 @@ packages:
react-dom:
optional: true
+ react-use@17.6.0:
+ resolution: {integrity: sha512-OmedEScUMKFfzn1Ir8dBxiLLSOzhKe/dPZwVxcujweSj45aNM7BEGPb9BEVIgVEqEXx6f3/TsXzwIktNgUR02g==}
+ peerDependencies:
+ react: '*'
+ react-dom: '*'
+
react-zdog@1.2.2:
resolution: {integrity: sha512-Ix7ALha91aOEwiHuxumCeYbARS5XNpc/w0v145oGkM6poF/CvhKJwzLhM5sEZbtrghMA+psAhOJkCTzJoseicA==}
@@ -2900,6 +2964,9 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
+ rtl-css-js@1.16.1:
+ resolution: {integrity: sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -2921,6 +2988,10 @@ packages:
scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
+ screenfull@5.2.0:
+ resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==}
+ engines: {node: '>=0.10.0'}
+
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -2953,6 +3024,10 @@ packages:
set-cookie-parser@2.7.1:
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
+ set-harmonic-interval@1.0.1:
+ resolution: {integrity: sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==}
+ engines: {node: '>=6.9'}
+
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -3023,6 +3098,10 @@ packages:
source-map-support@0.5.21:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
+ source-map@0.5.6:
+ resolution: {integrity: sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==}
+ engines: {node: '>=0.10.0'}
+
source-map@0.5.7:
resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==}
engines: {node: '>=0.10.0'}
@@ -3037,6 +3116,9 @@ packages:
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
+ stack-generator@2.0.10:
+ resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==}
+
stack-utils@2.0.6:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'}
@@ -3044,6 +3126,12 @@ packages:
stackframe@1.3.4:
resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==}
+ stacktrace-gps@3.1.2:
+ resolution: {integrity: sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==}
+
+ stacktrace-js@2.0.2:
+ resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==}
+
stacktrace-parser@0.1.11:
resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==}
engines: {node: '>=6'}
@@ -3076,6 +3164,9 @@ packages:
resolution: {integrity: sha512-9eoEdUGD68XjvW/+BWo5Ir7d5+cXChcQMAuYHmgrHImz/jIfabxNj4pc+6DxVhxHnW92+ZzMVVUuxs4iTOXjsQ==}
engines: {node: '>=6'}
+ stylis@4.3.6:
+ resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
+
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
@@ -3133,6 +3224,10 @@ packages:
throat@5.0.0:
resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==}
+ throttle-debounce@3.0.1:
+ resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==}
+ engines: {node: '>=10'}
+
tinyglobby@0.2.14:
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
engines: {node: '>=12.0.0'}
@@ -3144,6 +3239,9 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ toggle-selection@1.0.6:
+ resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==}
+
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
@@ -3166,9 +3264,15 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
+ ts-easing@0.2.0:
+ resolution: {integrity: sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==}
+
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+ tween-functions@1.2.0:
+ resolution: {integrity: sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA==}
+
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -4374,6 +4478,8 @@ snapshots:
dependencies:
'@types/istanbul-lib-report': 3.0.3
+ '@types/js-cookie@2.2.7': {}
+
'@types/json-schema@7.0.15': {}
'@types/node@24.0.0':
@@ -4518,6 +4624,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@xobotyi/scrollbar-width@1.9.5': {}
+
'@xstate/react@1.6.3(@types/react@19.1.6)(react@19.1.0)(xstate@4.38.3)':
dependencies:
react: 19.1.0
@@ -4861,6 +4969,10 @@ snapshots:
cookie@1.0.2: {}
+ copy-to-clipboard@3.3.3:
+ dependencies:
+ toggle-selection: 1.0.6
+
cors@2.8.5:
dependencies:
object-assign: 4.1.1
@@ -4888,6 +5000,10 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ css-in-js-utils@3.1.0:
+ dependencies:
+ hyphenate-style-name: 1.1.0
+
css-select@5.1.0:
dependencies:
boolbase: 1.0.0
@@ -4896,6 +5012,11 @@ snapshots:
domutils: 3.2.2
nth-check: 2.1.1
+ css-tree@1.1.3:
+ dependencies:
+ mdn-data: 2.0.14
+ source-map: 0.6.1
+
css-what@6.1.0: {}
csstype@3.1.3: {}
@@ -5216,6 +5337,10 @@ snapshots:
fast-levenshtein@2.0.6: {}
+ fast-shallow-equal@1.0.0: {}
+
+ fastest-stable-stringify@2.0.2: {}
+
fastq@1.19.1:
dependencies:
reusify: 1.1.0
@@ -5409,6 +5534,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ hyphenate-style-name@1.1.0: {}
+
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
@@ -5444,6 +5571,10 @@ snapshots:
inherits@2.0.4: {}
+ inline-style-prefixer@7.0.1:
+ dependencies:
+ css-in-js-utils: 3.1.0
+
invariant@2.2.4:
dependencies:
loose-envify: 1.4.0
@@ -5571,6 +5702,8 @@ snapshots:
jiti@2.4.2: {}
+ js-cookie@2.2.1: {}
+
js-tokens@4.0.0: {}
js-yaml@3.14.1:
@@ -5705,6 +5838,8 @@ snapshots:
math-intrinsics@1.1.0: {}
+ mdn-data@2.0.14: {}
+
media-typer@1.1.0: {}
memoize-one@5.2.1: {}
@@ -5983,6 +6118,19 @@ snapshots:
ms@2.1.3: {}
+ nano-css@5.6.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.0
+ css-tree: 1.1.3
+ csstype: 3.1.3
+ fastest-stable-stringify: 2.0.2
+ inline-style-prefixer: 7.0.1
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ rtl-css-js: 1.16.1
+ stacktrace-js: 2.0.2
+ stylis: 4.3.6
+
nanoid@3.3.11: {}
natural-compare@1.4.0: {}
@@ -6184,6 +6332,11 @@ snapshots:
iconv-lite: 0.6.3
unpipe: 1.0.0
+ react-confetti@6.4.0(react@19.1.0):
+ dependencies:
+ react: 19.1.0
+ tween-functions: 1.2.0
+
react-devtools-core@6.1.2:
dependencies:
shell-quote: 1.8.3
@@ -6347,6 +6500,11 @@ snapshots:
- three
- zdog
+ react-universal-interface@0.6.2(react@19.1.0)(tslib@2.8.1):
+ dependencies:
+ react: 19.1.0
+ tslib: 2.8.1
+
react-use-gesture@8.0.1(react@19.1.0):
dependencies:
react: 19.1.0
@@ -6357,6 +6515,25 @@ snapshots:
optionalDependencies:
react-dom: 19.1.0(react@19.1.0)
+ react-use@17.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ dependencies:
+ '@types/js-cookie': 2.2.7
+ '@xobotyi/scrollbar-width': 1.9.5
+ copy-to-clipboard: 3.3.3
+ fast-deep-equal: 3.1.3
+ fast-shallow-equal: 1.0.0
+ js-cookie: 2.2.1
+ nano-css: 5.6.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ react-universal-interface: 0.6.2(react@19.1.0)(tslib@2.8.1)
+ resize-observer-polyfill: 1.5.1
+ screenfull: 5.2.0
+ set-harmonic-interval: 1.0.1
+ throttle-debounce: 3.0.1
+ ts-easing: 0.2.0
+ tslib: 2.8.1
+
react-zdog@1.2.2:
dependencies:
react: 18.3.1
@@ -6427,6 +6604,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ rtl-css-js@1.16.1:
+ dependencies:
+ '@babel/runtime': 7.27.6
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -6447,6 +6628,8 @@ snapshots:
scheduler@0.26.0: {}
+ screenfull@5.2.0: {}
+
semver@6.3.1: {}
semver@7.7.2: {}
@@ -6507,6 +6690,8 @@ snapshots:
set-cookie-parser@2.7.1: {}
+ set-harmonic-interval@1.0.1: {}
+
setprototypeof@1.2.0: {}
shebang-command@2.0.0:
@@ -6608,6 +6793,8 @@ snapshots:
buffer-from: 1.1.2
source-map: 0.6.1
+ source-map@0.5.6: {}
+
source-map@0.5.7: {}
source-map@0.6.1: {}
@@ -6618,12 +6805,27 @@ snapshots:
sprintf-js@1.0.3: {}
+ stack-generator@2.0.10:
+ dependencies:
+ stackframe: 1.3.4
+
stack-utils@2.0.6:
dependencies:
escape-string-regexp: 2.0.0
stackframe@1.3.4: {}
+ stacktrace-gps@3.1.2:
+ dependencies:
+ source-map: 0.5.6
+ stackframe: 1.3.4
+
+ stacktrace-js@2.0.2:
+ dependencies:
+ error-stack-parser: 2.1.4
+ stack-generator: 2.0.10
+ stacktrace-gps: 3.1.2
+
stacktrace-parser@0.1.11:
dependencies:
type-fest: 0.7.1
@@ -6650,6 +6852,8 @@ snapshots:
dependencies:
react-lib-adler32: 1.0.3
+ stylis@4.3.6: {}
+
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
@@ -6706,6 +6910,8 @@ snapshots:
throat@5.0.0: {}
+ throttle-debounce@3.0.1: {}
+
tinyglobby@0.2.14:
dependencies:
fdir: 6.4.5(picomatch@4.0.2)
@@ -6717,6 +6923,8 @@ snapshots:
dependencies:
is-number: 7.0.0
+ toggle-selection@1.0.6: {}
+
toidentifier@1.0.1: {}
touch@3.1.1: {}
@@ -6731,8 +6939,12 @@ snapshots:
dependencies:
typescript: 5.8.3
+ ts-easing@0.2.0: {}
+
tslib@2.8.1: {}
+ tween-functions@1.2.0: {}
+
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
diff --git a/server/controllers/planSocketController.js b/server/controllers/planSocketController.js
index ea08801..c1a650b 100644
--- a/server/controllers/planSocketController.js
+++ b/server/controllers/planSocketController.js
@@ -1,4 +1,4 @@
-import { streamChat } from '../services/gptService.js';
+import { streamChat, streamChatWithFollowUp } from '../services/gptService.js';
import { buildPromptMessages } from '../utils/promptBuilder.js';
export const handleCarouselSelection = async (
@@ -77,7 +77,10 @@ export const handleCarouselSelection = async (
}
};
-export const handlePlanRecommend = async (socket, { sessionId, message }) => {
+export const handlePlanRecommend = async (
+ socket,
+ { sessionId, message, history },
+) => {
try {
// 입력 검증
if (!sessionId || !message) {
@@ -88,12 +91,16 @@ export const handlePlanRecommend = async (socket, { sessionId, message }) => {
});
return;
}
+ console.log('수신메세지', message);
+ console.log('대화히스토리', history?.length || 0, '개');
- // MongoDB 세션 관리 제거 - 로컬스토리지에서 관리
- // 기본 메시지 형태로 프롬프트 생성 (히스토리 없이)
+ // 🔧 히스토리가 있으면 사용, 없으면 기본 메시지만 사용
const plans = '';
- const basicMessages = [{ role: 'user', content: message }];
-
+ const basicMessages =
+ history && history.length > 0
+ ? history
+ : [{ role: 'user', content: message }];
+ console.log('프롬프트메세지', basicMessages.length, '개');
let messages;
try {
messages = buildPromptMessages(plans, basicMessages);
@@ -114,14 +121,13 @@ export const handlePlanRecommend = async (socket, { sessionId, message }) => {
// GPT 스트리밍 호출
try {
- await streamChat(
+ await streamChatWithFollowUp(
messages,
socket,
(chunk) => {
assistantReply += chunk;
},
(funcInfo) => {
- functionCallInfo = funcInfo;
console.log('🔧 Function call detected:', funcInfo);
},
);
@@ -129,12 +135,6 @@ export const handlePlanRecommend = async (socket, { sessionId, message }) => {
console.error('❌ GPT streaming error:', gptError);
return;
}
-
- // MongoDB 저장 제거 - 로컬스토리지에서 관리
- console.log(
- '✅ Message processed successfully (saved to localStorage):',
- sessionId,
- );
} catch (error) {
console.error('❌ handlePlanRecommend error:', error);
socket.emit('error', {
@@ -148,136 +148,3 @@ export const handlePlanRecommend = async (socket, { sessionId, message }) => {
});
}
};
-
-// 새로 추가: 캐러셀 선택 상태 업데이트 함수
-export const handleUpdateCarouselSelection = async (
- socket,
- { sessionId, messageIndex, selectedItem },
-) => {
- try {
- console.log('🔄 Updating carousel selection:', {
- sessionId,
- messageIndex,
- selectedItem,
- });
-
- if (!sessionId || messageIndex === undefined) {
- socket.emit('error', {
- type: 'INVALID_INPUT',
- message: 'sessionId와 messageIndex가 필요합니다.',
- details: { sessionId, messageIndex },
- });
- return;
- }
-
- let session;
- try {
- session = await ChatSession.findOne({ sessionId });
- if (!session) {
- console.error('❌ Session not found:', sessionId);
- return;
- }
- } catch (dbError) {
- console.error('❌ Database error:', dbError);
- socket.emit('error', {
- type: 'DATABASE_ERROR',
- message: '세션 데이터 처리 중 오류가 발생했습니다.',
- details: { sessionId, error: dbError.message },
- });
- return;
- }
-
- // 해당 메시지 찾기
- const targetMessage = session.messages[messageIndex];
- if (!targetMessage) {
- console.error('❌ Message not found:', {
- messageIndex,
- totalMessages: session.messages.length,
- });
- socket.emit('error', {
- type: 'INVALID_INPUT',
- message: '해당 인덱스의 메시지를 찾을 수 없습니다.',
- details: { messageIndex, totalMessages: session.messages.length },
- });
- return;
- }
-
- if (targetMessage.role !== 'assistant') {
- console.error('❌ Not an assistant message:', {
- messageIndex,
- role: targetMessage.role,
- });
- socket.emit('error', {
- type: 'INVALID_INPUT',
- message: 'assistant 메시지가 아닙니다.',
- details: { messageIndex, role: targetMessage.role },
- });
- return;
- }
-
- if (targetMessage.type !== 'function_call') {
- console.error('❌ Not a function_call message:', {
- messageIndex,
- type: targetMessage.type,
- });
- socket.emit('error', {
- type: 'INVALID_INPUT',
- message: 'function_call 메시지가 아닙니다.',
- details: { messageIndex, type: targetMessage.type },
- });
- return;
- }
-
- if (
- !targetMessage.data ||
- targetMessage.data.name !== 'requestCarouselButtons'
- ) {
- console.error('❌ Not a carousel function_call:', {
- messageIndex,
- data: targetMessage.data,
- });
- socket.emit('error', {
- type: 'INVALID_INPUT',
- message: '캐러셀 function_call이 아닙니다.',
- details: { messageIndex, data: targetMessage.data },
- });
- return;
- }
-
- // data 필드 업데이트 (안전하게)
- if (!targetMessage.data) {
- targetMessage.data = {};
- }
-
- targetMessage.data.selectedItem = selectedItem;
- targetMessage.data.isSelected = true;
- targetMessage.data.updatedAt = new Date();
-
- try {
- session.markModified('messages');
- await session.save();
- console.log('✅ Carousel selection updated successfully:', sessionId);
-
- // 클라이언트에 업데이트 완료 알림
- socket.emit('carousel-selection-updated', {
- messageIndex,
- selectedItem,
- isSelected: true,
- });
- } catch (saveError) {
- console.error('❌ Carousel selection update error:', saveError);
- socket.emit('error', {
- type: 'SESSION_SAVE_ERROR',
- message: '선택 상태 업데이트 중 오류가 발생했습니다.',
- details: { sessionId, error: saveError.message },
- });
- }
- } catch (error) {
- console.error('❌ handleUpdateCarouselSelection error:', error);
- socket.emit('error', {
- type: 'CONTROLLER_ERROR',
- message: '선택 상태 업데이트 중 예상치 못한 오류가 발생했습니다.',
- details: { sessionId, error: error.message },
- });
- }
-};
diff --git a/server/services/gptErrorHandler.js b/server/services/gptErrorHandler.js
new file mode 100644
index 0000000..d056342
--- /dev/null
+++ b/server/services/gptErrorHandler.js
@@ -0,0 +1,82 @@
+import { ErrorType } from '../utils/constants.js';
+
+/**
+ * GPT 서비스 에러를 분류하고 클라이언트에게 적절한 에러 메시지를 전송합니다.
+ * @param {Error} error - 발생한 에러 객체
+ * @param {Socket} socket - 소켓 객체
+ */
+export const handleGPTError = (error, socket) => {
+ console.error('❌ GPT Service Error:', error);
+
+ // 타임아웃 에러
+ if (error.message === 'REQUEST_TIMEOUT') {
+ socket.emit('error', {
+ type: ErrorType.REQUEST_TIMEOUT,
+ message: '⏱️ 응답 시간이 초과되었습니다. 다시 시도해주세요.',
+ details: {
+ timeout: '30초',
+ message: error.message,
+ },
+ });
+ }
+ // OpenAI API 관련 에러
+ else if (error.response) {
+ socket.emit('error', {
+ type: ErrorType.OPENAI_API_ERROR,
+ message: 'AI 서비스 연결에 문제가 발생했습니다.',
+ details: {
+ status: error.response.status,
+ statusText: error.response.statusText,
+ message: error.message,
+ },
+ });
+ }
+ // 네트워크 에러
+ else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
+ socket.emit('error', {
+ type: ErrorType.NETWORK_ERROR,
+ message: '네트워크 연결에 문제가 발생했습니다.',
+ details: {
+ code: error.code,
+ message: error.message,
+ },
+ });
+ }
+ // 스트리밍 에러
+ else if (error.name === 'AbortError') {
+ socket.emit('error', {
+ type: ErrorType.STREAM_ABORTED,
+ message: '스트리밍이 중단되었습니다.',
+ details: {
+ message: error.message,
+ },
+ });
+ }
+ // 기타 에러
+ else {
+ socket.emit('error', {
+ type: ErrorType.UNKNOWN_ERROR,
+ message: '예상치 못한 오류가 발생했습니다.',
+ details: {
+ message: error.message,
+ stack: error.stack,
+ },
+ });
+ }
+};
+
+/**
+ * 함수 호출 관련 에러를 처리합니다.
+ * @param {string} type - 에러 타입
+ * @param {string} message - 에러 메시지
+ * @param {Object} details - 에러 상세 정보
+ * @param {Socket} socket - 소켓 객체
+ */
+export const handleFunctionError = (type, message, details, socket) => {
+ socket.emit('loading-end');
+ socket.emit('error', {
+ type,
+ message,
+ details,
+ });
+};
diff --git a/server/services/gptFuncCallTest.js b/server/services/gptFuncCallTest.js
deleted file mode 100644
index 84f7407..0000000
--- a/server/services/gptFuncCallTest.js
+++ /dev/null
@@ -1,119 +0,0 @@
-import dotenv from 'dotenv';
-import { OpenAI } from 'openai';
-import { InputRoleEnum } from '../utils/constants.js';
-import { BASE_PLAN_TOOL_DEF } from '../utils/gptTools.js';
-import {
- getAffordablePlans,
- getOTTBundlePlans,
- getPlans,
- getUnlimitedDataPlans,
-} from './gptFuncDefinitions.js';
-
-dotenv.config();
-
-const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
-
-const tools = [
- {
- ...BASE_PLAN_TOOL_DEF,
- name: 'getPlans',
- description: '요금제 전체 목록을 반환합니다.',
- },
- {
- ...BASE_PLAN_TOOL_DEF,
- name: 'getAffordablePlans',
- description: '월정액이 5만원 이하인 저렴한 요금제 목록을 반환합니다.',
- },
- {
- ...BASE_PLAN_TOOL_DEF,
- name: 'getUnlimitedDataPlans',
- description: '데이터가 무제한인 요금제 목록을 반환합니다.',
- },
- {
- ...BASE_PLAN_TOOL_DEF,
- name: 'getOTTBundlePlans',
- description: 'OTT 서비스가 결합된 요금제 목록을 반환합니다.',
- },
-];
-
-export const getDataByName = async (name) => {
- switch (name) {
- case 'getPlans':
- return await getPlans();
- case 'getAffordablePlans':
- return await getAffordablePlans();
- case 'getUnlimitedDataPlans':
- return await getUnlimitedDataPlans();
- case 'getOTTBundlePlans':
- return await getOTTBundlePlans();
- }
-};
-
-export const emitRecommendReasonByGuide = async (input, socket) => {
- while (true) {
- const stream = await openai.responses.create({
- model: 'gpt-4.1',
- input,
- tools,
- temperature: 0.3,
- max_output_tokens: 400,
- top_p: 1.0,
- stream: true,
- });
- let isFunctionCalling = false;
-
- for await (const event of stream) {
- // function calling으로 데이터 불러오기
- if (
- event.type === 'response.output_item.added' &&
- event.item.type === 'function_call'
- ) {
- isFunctionCalling = true;
-
- const toolCall = event.item;
- console.log('call function:', toolCall.name);
- input.push(toolCall);
-
- const data = await getDataByName(toolCall.name);
- input.push({
- type: 'function_call_output',
- call_id: toolCall.call_id,
- output: JSON.stringify(data),
- });
-
- input.push({
- role: InputRoleEnum.USER,
- content: '참고: 데이터에서 -1은 무제한을 의미함',
- });
- }
-
- // 대화 내용 저장
- if (event.item_id && event.item_id.startsWith('msg')) {
- socket.emit('recommend-plan-by-guide', event);
-
- if (event.type === 'response.output_text.done') {
- input.push({
- role: InputRoleEnum.ASSISTANT,
- content: event.text,
- });
- }
- }
- }
-
- if (isFunctionCalling) continue;
- return input;
- }
-};
-
-export const getPlanIds = async (input) => {
- const res = await openai.responses.create({
- model: 'gpt-4.1-nano',
- input,
- temperature: 0.3,
- max_output_tokens: 200,
- top_p: 1.0,
- });
- const endIndex = res.output_text.length - 1;
-
- return res.output_text.slice(1, endIndex).split(', ');
-};
diff --git a/server/services/gptFuncDefinitions.js b/server/services/gptFuncDefinitions.js
index 33eafe6..52c4e6a 100644
--- a/server/services/gptFuncDefinitions.js
+++ b/server/services/gptFuncDefinitions.js
@@ -159,3 +159,185 @@ export const getPlanResultsByIds = async (planIds) => {
throw error;
}
};
+
+/** 조건에 맞는 요금제 검색 (최대 3개) */
+export const searchPlansFromDB = async (searchConditions) => {
+ try {
+ const {
+ category,
+ maxMonthlyFee,
+ minMonthlyFee, // 🔧 최소 월 요금 추가
+ minDataGb,
+ ageGroup,
+ isPopular,
+ preferredAddons, // 🔧 선호하는 부가서비스 추가 (예: ["MEDIA", "OTT", "MUSIC"])
+ limit = 3,
+ } = searchConditions;
+
+ console.log('🔍 요금제 검색 조건:', searchConditions);
+
+ // 동적 쿼리 조건 생성
+ const query = {};
+
+ // 카테고리 조건
+ if (category) {
+ query.category = category;
+ }
+
+ // 월 요금 조건 (범위 검색)
+ if (maxMonthlyFee || minMonthlyFee) {
+ const monthlyFeeCondition = {};
+ if (minMonthlyFee) {
+ monthlyFeeCondition.$gte = minMonthlyFee;
+ }
+ if (maxMonthlyFee) {
+ monthlyFeeCondition.$lte = maxMonthlyFee;
+ }
+ query.monthlyFee = monthlyFeeCondition;
+ }
+
+ // 최소 데이터량 조건
+ if (minDataGb !== undefined) {
+ if (minDataGb === -1) {
+ // 무제한 데이터 요청
+ query.dataGb = -1;
+ } else {
+ // 특정 데이터량 이상 요청
+ query.$or = [
+ { dataGb: -1 }, // 무제한도 포함
+ { dataGb: { $gte: minDataGb } }, // 지정된 데이터량 이상
+ ];
+ }
+ }
+
+ // 연령대 조건
+ if (ageGroup) {
+ query.ageGroup = ageGroup;
+ }
+
+ // 인기 요금제 조건
+ if (isPopular !== undefined) {
+ query.isPopular = isPopular;
+ }
+
+ // 🔧 부가서비스 조건 추가
+ if (preferredAddons && preferredAddons.length > 0) {
+ // 부가서비스 키워드를 포함하는 요금제 필터링
+ const addonConditions = [];
+
+ preferredAddons.forEach((addon) => {
+ switch (addon.toUpperCase()) {
+ case 'NETFLIX':
+ case 'NETFLEX':
+ addonConditions.push({
+ $or: [
+ { mediaAddons: { $regex: '넷플릭스', $options: 'i' } },
+ { premiumAddons: { $regex: '넷플릭스', $options: 'i' } },
+ ],
+ });
+ break;
+ case 'DISNEY':
+ case 'DISNEY+':
+ addonConditions.push({
+ $or: [
+ { mediaAddons: { $regex: '디즈니', $options: 'i' } },
+ { premiumAddons: { $regex: '디즈니', $options: 'i' } },
+ ],
+ });
+ break;
+ case 'TVING':
+ case '티빙':
+ addonConditions.push({
+ $or: [
+ { mediaAddons: { $regex: '티빙', $options: 'i' } },
+ { premiumAddons: { $regex: '티빙', $options: 'i' } },
+ ],
+ });
+ break;
+ case 'MUSIC':
+ case '음악':
+ addonConditions.push({
+ $or: [
+ { mediaAddons: { $regex: '바이브|지니뮤직', $options: 'i' } },
+ { premiumAddons: { $regex: '바이브|지니뮤직', $options: 'i' } },
+ ],
+ });
+ break;
+ case 'YOUTUBE':
+ case '유튜브':
+ addonConditions.push({
+ $or: [
+ { mediaAddons: { $regex: '유튜브', $options: 'i' } },
+ { premiumAddons: { $regex: '유튜브', $options: 'i' } },
+ ],
+ });
+ break;
+ case 'BOOK':
+ case '책':
+ case '독서':
+ addonConditions.push({
+ $or: [
+ { mediaAddons: { $regex: '밀리의 서재', $options: 'i' } },
+ { premiumAddons: { $regex: '밀리의 서재', $options: 'i' } },
+ ],
+ });
+ break;
+ case 'KIDS':
+ case '아이':
+ case '어린이':
+ addonConditions.push({
+ $or: [
+ { mediaAddons: { $regex: '아이들나라', $options: 'i' } },
+ {
+ premiumAddons: { $regex: '아이들나라|돌봄이', $options: 'i' },
+ },
+ ],
+ });
+ break;
+ case 'UPLAY':
+ case '유플레이':
+ addonConditions.push({
+ $or: [
+ { mediaAddons: { $regex: '유플레이', $options: 'i' } },
+ { premiumAddons: { $regex: '유플레이', $options: 'i' } },
+ ],
+ });
+ break;
+ case 'MEDIA':
+ case '미디어':
+ addonConditions.push({ mediaAddons: { $ne: null, $ne: '' } });
+ break;
+ case 'PREMIUM':
+ case '프리미엄':
+ addonConditions.push({ premiumAddons: { $ne: null, $ne: '' } });
+ break;
+ }
+ });
+
+ if (addonConditions.length > 0) {
+ // 부가서비스 조건들을 AND로 연결 (모든 조건을 만족하는 요금제)
+ query.$and = query.$and
+ ? [...query.$and, ...addonConditions]
+ : addonConditions;
+ }
+ }
+
+ console.log('📋 생성된 MongoDB 쿼리:', JSON.stringify(query, null, 2));
+
+ // 쿼리 실행
+ const plans = await Plan.find(query)
+ .select(EXCLUDED_FIELDS)
+ .sort({
+ isPopular: -1, // 인기 요금제 우선
+ monthlyFee: 1, // 가격 낮은 순
+ })
+ .limit(limit);
+
+ console.log(`✅ 검색 결과: ${plans.length}개 요금제 찾음`);
+
+ return { plans: plans };
+ } catch (error) {
+ console.error('searchPlansFromDB >>', error);
+ throw error;
+ }
+};
diff --git a/server/services/gptFunctionHandler.js b/server/services/gptFunctionHandler.js
new file mode 100644
index 0000000..ba2a71c
--- /dev/null
+++ b/server/services/gptFunctionHandler.js
@@ -0,0 +1,255 @@
+import { extractMetadata } from '../utils/metadataExtractor.js';
+import { handleFunctionError } from './gptErrorHandler.js';
+import { searchPlansFromDB } from './gptFuncDefinitions.js';
+import {
+ ErrorType,
+ SocketEvent,
+ OTTServices,
+ OXOptions,
+ LoadingType,
+} from '../utils/constants.js';
+
+/**
+ * 함수 인자를 JavaScript 객체나 JSON으로 파싱합니다.
+ * @param {string} functionArgsRaw - 원시 함수 인자 문자열
+ * @returns {Object} 파싱된 인자 객체
+ */
+const parseFunctionArgs = (functionArgsRaw) => {
+ if (!functionArgsRaw) return {};
+
+ try {
+ // JavaScript 객체 형식을 JSON으로 변환
+ let fixedJson = functionArgsRaw
+ // 1. 키에 따옴표 추가 (단어로 시작하는 키들만)
+ .replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":')
+ // 2. 작은따옴표를 큰따옴표로 변환
+ .replace(/'/g, '"')
+ // 3. 숫자 뒤의 불필요한 소수점 제거 (-1.0 → -1)
+ .replace(/(-?\d+)\.0(?=[,\s\]\}])/g, '$1')
+ // 4. 줄바꿈과 연속된 공백 정리
+ .replace(/\n\s*/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+
+ console.log(
+ '🔄 변환 시도 (처음 200자):',
+ fixedJson.substring(0, 200) + '...',
+ );
+
+ return JSON.parse(fixedJson);
+ } catch (secondParseError) {
+ // eval 방식으로 재시도
+ try {
+ console.warn('🔄 eval 방식으로 재시도...');
+ return eval(`(${functionArgsRaw})`);
+ } catch (evalError) {
+ console.error('❌ 최종 JSON 파싱 실패:', secondParseError);
+ console.error('❌ eval 방식도 실패:', evalError);
+ console.log('🔍 원본:', functionArgsRaw);
+ console.log('🔍 변환 시도:', fixedJson);
+ throw new Error('Function arguments 파싱에 실패했습니다.');
+ }
+ }
+};
+
+/**
+ * 각 함수별 처리 로직을 실행합니다.
+ * @param {string} functionName - 호출할 함수 이름
+ * @param {Object} args - 함수 인자
+ * @param {Socket} socket - 소켓 객체
+ * @returns {Object} 함수 실행 결과
+ */
+export const executeFunctionCall = async (functionName, args, socket) => {
+ switch (functionName) {
+ case 'requestOTTServiceList': {
+ socket.emit(SocketEvent.LOADING_END);
+ socket.emit(SocketEvent.OTT_SERVICE_LIST, {
+ question: '어떤 OTT 서비스를 함께 사용 중이신가요?',
+ options: OTTServices,
+ });
+ return { success: true, functionName, result: 'OTT 서비스 선택지 제공' };
+ }
+
+ case 'requestOXCarouselButtons': {
+ socket.emit(SocketEvent.LOADING_END);
+ socket.emit(SocketEvent.OX_CAROUSEL_BUTTONS, {
+ options: OXOptions,
+ });
+ break;
+ }
+
+ case 'requestCarouselButtons': {
+ const { items } = args;
+ if (!items) {
+ handleFunctionError(
+ ErrorType.MISSING_FUNCTION_ARGS,
+ 'requestCarouselButtons에 필요한 items가 없습니다.',
+ { functionName, args },
+ socket,
+ );
+ return;
+ }
+ socket.emit(SocketEvent.LOADING_END);
+ socket.emit(SocketEvent.CAROUSEL_BUTTONS, items);
+ break;
+ }
+
+ case 'searchPlans': {
+ console.log('🔍 searchPlans 함수 호출됨:', args);
+
+ socket.emit(SocketEvent.LOADING, {
+ type: functionName?.includes('Plan')
+ ? LoadingType.DB_CALLING
+ : LoadingType.SEARCHING,
+ functionName: functionName,
+ });
+ try {
+ // MongoDB에서 조건에 맞는 요금제 검색
+ const result = await searchPlansFromDB(args);
+ const { plans } = result;
+
+ console.log(`📋 검색된 요금제 수: ${plans.length}개`);
+
+ if (plans.length === 0) {
+ console.warn('⚠️ 조건에 맞는 요금제가 없습니다.');
+ // 검색 결과가 없어도 빈 배열을 전송
+ socket.emit(SocketEvent.LOADING_END);
+ socket.emit(SocketEvent.PLAN_LISTS, []);
+ return {
+ success: true,
+ functionName,
+ result: 'empty',
+ plansCount: 0,
+ };
+ } else {
+ // 검색된 요금제를 클라이언트에 전송
+ socket.emit(SocketEvent.LOADING_END);
+ socket.emit(SocketEvent.PLAN_LISTS, plans);
+ return {
+ success: true,
+ functionName,
+ result: 'found',
+ plansCount: plans.length,
+ planNames: plans.map((p) => p.name),
+ };
+ }
+ } catch (dbError) {
+ console.error('❌ DB 조회 실패:', dbError);
+ // 에러 발생 시에도 로딩 종료
+ socket.emit(SocketEvent.LOADING_END);
+ handleFunctionError(
+ ErrorType.FUNCTION_EXECUTION_ERROR,
+ '요금제 검색 중 오류가 발생했습니다.',
+ { functionName, args, error: dbError.message },
+ socket,
+ );
+ return { success: false, functionName, error: dbError.message };
+ }
+ break;
+ }
+
+ case 'showPlanLists': {
+ const { plans } = args;
+ if (!plans) {
+ handleFunctionError(
+ ErrorType.MISSING_FUNCTION_ARGS,
+ 'showPlanLists에 필요한 plans가 없습니다.',
+ { functionName, args },
+ socket,
+ );
+ return;
+ }
+ socket.emit(SocketEvent.LOADING_END);
+ socket.emit(SocketEvent.PLAN_LISTS, plans);
+ break;
+ }
+
+ case 'requestTextCard': {
+ const { title, description, url, buttonText, imageUrl } = args;
+ if (!title || !description || !url || !buttonText) {
+ handleFunctionError(
+ ErrorType.MISSING_FUNCTION_ARGS,
+ 'requestTextCard에 필요한 title, description, url, buttonText가 없습니다.',
+ { functionName, args },
+ socket,
+ );
+ return;
+ }
+
+ // imageUrl이 없으면 URL에서 메타데이터 추출
+ let finalImageUrl = await extractMetadata(url);
+ console.log('📸 추출된 이미지 URL:', finalImageUrl);
+
+ socket.emit(SocketEvent.LOADING_END);
+ socket.emit(SocketEvent.TEXT_CARD, {
+ title,
+ description,
+ url,
+ buttonText,
+ imageUrl: finalImageUrl,
+ });
+ break;
+ }
+
+ case 'showFirstCardList': {
+ socket.emit(SocketEvent.LOADING_END);
+ socket.emit(SocketEvent.FIRST_CARD_LIST);
+ break;
+ }
+
+ default:
+ handleFunctionError(
+ ErrorType.UNKNOWN_FUNCTION,
+ `알 수 없는 function: ${functionName}`,
+ { functionName, args },
+ socket,
+ );
+ return { success: false, functionName, error: 'Unknown function' };
+ }
+};
+
+/**
+ * 함수 호출을 처리합니다.
+ * @param {string} functionName - 함수 이름
+ * @param {string} functionArgsRaw - 원시 함수 인자
+ * @param {Socket} socket - 소켓 객체
+ */
+export const handleFunctionCall = async (
+ functionName,
+ functionArgsRaw,
+ socket,
+) => {
+ try {
+ const args = parseFunctionArgs(functionArgsRaw);
+
+ const result = await executeFunctionCall(functionName, args, socket);
+ return result;
+ } catch (functionError) {
+ console.error(`Function call 처리 실패 (${functionName}):`, functionError);
+
+ if (functionError.message === 'Function arguments 파싱에 실패했습니다.') {
+ handleFunctionError(
+ ErrorType.FUNCTION_ARGS_PARSE_ERROR,
+ 'Function arguments 파싱에 실패했습니다.',
+ {
+ functionName,
+ rawArgs: functionArgsRaw,
+ parseError: functionError.message,
+ },
+ socket,
+ );
+ } else {
+ handleFunctionError(
+ ErrorType.FUNCTION_EXECUTION_ERROR,
+ '기능 처리 중 오류가 발생했습니다.',
+ {
+ functionName,
+ args: functionArgsRaw,
+ error: functionError.message,
+ },
+ socket,
+ );
+ return { success: false, functionName, error: functionError.message };
+ }
+ }
+};
diff --git a/server/services/gptService.js b/server/services/gptService.js
index 1c2c23c..ae9b331 100644
--- a/server/services/gptService.js
+++ b/server/services/gptService.js
@@ -1,747 +1,382 @@
import dotenv from 'dotenv';
import OpenAI from 'openai';
-import axios from 'axios';
-import * as cheerio from 'cheerio';
+import { GPTConfig, SocketEvent, LoadingType } from '../utils/constants.js';
+import { handleFunctionCall } from './gptFunctionHandler.js';
+import { handleGPTError } from './gptErrorHandler.js';
+import { GPT_TOOLS } from './gptToolDefinitions.js';
dotenv.config();
export const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
-// 메타데이터 추출 함수
-const extractMetadata = async (url) => {
- try {
- const response = await axios.get(url, {
- timeout: 10000,
- headers: {
- 'User-Agent':
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
- },
- maxRedirects: 5,
- });
-
- const html = response.data;
- const $ = cheerio.load(html);
-
- const getMetaContent = (selector) => {
- const element = $(selector);
- return element.attr('content') || element.text() || null;
- };
-
- let imageUrl =
- getMetaContent('meta[property="og:image"]') ||
- getMetaContent('meta[name="twitter:image"]') ||
- null;
-
- // 상대 URL을 절대 URL로 변환
- if (imageUrl && !imageUrl.startsWith('http')) {
- const validUrl = new URL(url);
- if (imageUrl.startsWith('//')) {
- imageUrl = validUrl.protocol + imageUrl;
- } else if (imageUrl.startsWith('/')) {
- imageUrl = validUrl.origin + imageUrl;
- } else {
- imageUrl = validUrl.origin + '/' + imageUrl;
- }
- }
-
- return imageUrl;
- } catch (error) {
- console.warn('메타데이터 추출 실패:', error.message);
- return null;
- }
-};
-
+// 역질문 전용 도구들 (캐러셀, OX, OTT 버튼만)
+const FOLLOWUP_TOOLS = GPT_TOOLS.filter((tool) =>
+ [
+ 'requestCarouselButtons',
+ 'requestOXCarouselButtons',
+ 'requestOTTServiceList',
+ ].includes(tool.name),
+);
+let usedTotalTokens = 0;
+
+/**
+ * GPT 스트림 채팅을 처리합니다.
+ * @param {Array} messages - 채팅 메시지 배열
+ * @param {Socket} socket - 소켓 객체
+ * @param {Function} onDelta - 델타 콜백 함수
+ * @param {string} model - 사용할 GPT 모델 (기본값: GPTConfig.MODEL)
+ * @returns {Promise<{ hasFunctionCalls: boolean, functionResults: Array }>}
+ */
export const streamChat = async (
messages,
socket,
onDelta,
- onFunctionCall = null,
+ model = GPTConfig.MODEL,
) => {
try {
- // 타임아웃 설정 (30초)
- const timeoutMs = 30000;
- const timeoutPromise = new Promise((_, reject) => {
- setTimeout(() => reject(new Error('REQUEST_TIMEOUT')), timeoutMs);
- });
-
- const streamPromise = openai.chat.completions.create({
- model: 'gpt-4.1',
- messages,
+ const stream = await openai.responses.create({
+ model: model,
+ input: messages,
stream: true,
- tools: [
- {
- type: 'function',
- function: {
- name: 'requestOTTServiceList',
- description:
- '유저에게 통신사와 연결된 OTT 서비스 목록을 선택하도록 응답 받습니다.',
- parameters: { type: 'object', properties: {} },
- },
- },
- {
- type: 'function',
- function: {
- name: 'requestOXCarouselButtons',
- description:
- '유저에게 예/아니오로만 대답할 수 있는 선택지를 캐러셀 형태로 제공합니다.',
- parameters: { type: 'object', properties: {} },
- },
- },
- {
- type: 'function',
- function: {
- name: 'requestCarouselButtons',
- description:
- '유저에게 짧은 키워드나 명사형 선택지를 가로 스크롤 캐러셀 형태로 제공합니다. 통신사명, 요금대, 데이터량, 기술(5G/LTE) 등 단순한 카테고리 선택에 사용합니다.',
- parameters: {
- type: 'object',
- properties: {
- items: {
- type: 'array',
- description: '캐러셀 버튼으로 보여줄 항목 리스트',
- items: {
- type: 'object',
- properties: {
- id: {
- type: 'string',
- description: '항목 고유 ID 또는 태그',
- },
- label: {
- type: 'string',
- description: '버튼에 보여질 텍스트',
- },
- },
- required: ['id', 'label'],
- },
- },
- },
- required: ['items'],
- },
- },
- },
- {
- type: 'function',
- function: {
- name: 'showPlanLists',
- description:
- '유저에게 여러 요금제 상세 정보를 카드 형식으로 제공합니다. 보통 3개 이상의 요금제를 추천할 때 사용합니다.',
- parameters: {
- type: 'object',
- properties: {
- plans: {
- type: 'array',
- description: '추천할 요금제 목록',
- items: {
- type: 'object',
- properties: {
- _id: { type: 'string', description: '요금제 고유 ID' },
- category: {
- type: 'string',
- description: '요금제 카테고리 (5G, LTE 등)',
- },
- name: { type: 'string', description: '요금제 이름' },
- description: {
- type: 'string',
- description: '요금제 설명',
- },
- isPopular: {
- type: 'boolean',
- description: '인기 요금제 여부',
- },
- dataGb: {
- type: 'number',
- description: '기본 데이터 제공량 (-1은 무제한)',
- },
- sharedDataGb: {
- type: 'number',
- description: '공유/테더링 데이터 (GB)',
- },
- voiceMinutes: {
- type: 'number',
- description: '음성통화 시간 (-1은 무제한)',
- },
- addonVoiceMinutes: {
- type: 'number',
- description: '추가 음성통화 시간',
- },
- smsCount: {
- type: 'number',
- description: 'SMS 개수 (-1은 무제한)',
- },
- monthlyFee: { type: 'number', description: '월 요금' },
- optionalDiscountAmount: {
- type: 'number',
- description: '최대 할인 가능 금액',
- },
- ageGroup: {
- type: 'string',
- description: '대상 연령대 (ALL, YOUTH 등)',
- },
- detailUrl: {
- type: 'string',
- description: '자세히 보기 링크 URL',
- },
- bundleBenefit: {
- type: ['string', 'null'],
- description: '결합 할인 정보',
- },
- mediaAddons: {
- type: ['string', 'null'],
- description: '미디어 부가서비스',
- },
- premiumAddons: {
- type: ['string', 'null'],
- description: '프리미엄 부가서비스',
- },
- basicService: {
- type: 'string',
- description: '기본 제공 서비스',
- },
- },
- required: [
- '_id',
- 'category',
- 'name',
- 'description',
- 'isPopular',
- 'dataGb',
- 'sharedDataGb',
- 'voiceMinutes',
- 'addonVoiceMinutes',
- 'smsCount',
- 'monthlyFee',
- 'optionalDiscountAmount',
- 'ageGroup',
- 'detailUrl',
- 'basicService',
- ],
- },
- },
- },
- required: ['plans'],
- },
- },
- },
- {
- type: 'function',
- function: {
- name: 'requestTextCard',
- description:
- '유저에게 특정 웹사이트나 링크로 안내할 때 사용합니다. URL의 미리보기 이미지와 함께 카드 형태로 보여줍니다. 유플러스 사이트나 추천하는 외부 링크를 안내할 때 사용합니다.',
- parameters: {
- type: 'object',
- properties: {
- title: {
- type: 'string',
- description: '카드에 표시될 제목',
- },
- description: {
- type: 'string',
- description: '카드에 표시될 설명 텍스트',
- },
- url: {
- type: 'string',
- description: '안내할 링크 URL',
- },
- buttonText: {
- type: 'string',
- description:
- '버튼에 표시될 텍스트 (예: "자세히 보기", "사이트 방문하기")',
- },
- imageUrl: {
- type: 'string',
- description: '카드에 표시될 이미지 URL (선택사항)',
- },
- },
- required: ['title', 'description', 'url', 'buttonText'],
- },
- },
- },
- ],
+ tool_choice: 'auto',
+ tools: GPT_TOOLS,
+ parallel_tool_calls: false,
});
- const streamRes = await Promise.race([streamPromise, timeoutPromise]);
-
- let isFunctionCalled = false;
- let functionName = '';
- let functionArgsRaw = '';
- let accumulatedContent = ''; // 텍스트 누적용
-
- for await (const chunk of streamRes) {
- const delta = chunk.choices[0].delta;
+ // 함수 호출 정보 누적용
+ const functionCallMap = {}; // { [item_id]: { ... } }
+ const functionCalls = []; // 최종 실행용 배열
- // tool_calls 감지 (새로운 API 형식)
- if (delta.tool_calls && delta.tool_calls.length > 0) {
- // console.log('🛠️ Tool calls detected:', delta.tool_calls);
+ for await (const event of stream) {
+ // 1. 함수 호출 item 추가
+ if (
+ event.type === 'response.output_item.added' &&
+ event.item.type === 'function_call'
+ ) {
+ functionCallMap[event.item.id] = {
+ ...event.item,
+ arguments: '',
+ };
+
+ // 함수명에 따라 DB 호출/검색 타입 구분해서 로딩 emit
+ const functionName = event.item.name;
+ socket.emit(SocketEvent.LOADING, {
+ type: functionName?.includes('Plan')
+ ? LoadingType.DB_CALLING
+ : LoadingType.SEARCHING,
+ functionName: functionName,
+ });
+ console.log('🔄 로딩 시작:', functionName);
+ }
- // 처음 tool_calls 감지 시 로딩 시작
- if (!isFunctionCalled) {
- isFunctionCalled = true;
+ // 2. arguments 조각 누적
+ else if (event.type === 'response.function_call_arguments.delta') {
+ const id = event.item_id;
+ if (functionCallMap[id]) {
+ functionCallMap[id].arguments += event.delta;
+ }
+ }
- // Function calling 시작 - 로딩 상태 emit
- const toolCall = delta.tool_calls[0];
- const detectedFunctionName = toolCall.function?.name || 'unknown';
- socket.emit('loading', {
- type: detectedFunctionName.includes('Plan')
- ? 'dbcalling'
- : 'searching',
- functionName: detectedFunctionName,
+ // 3. arguments 누적 완료(함수 호출 하나 완성)
+ else if (event.type === 'response.function_call_arguments.done') {
+ const id = event.item_id;
+ const call = functionCallMap[id];
+ if (call) {
+ functionCalls.push({
+ functionName: call.name,
+ functionArgsRaw: call.arguments,
});
- console.log('🔄 로딩 시작:', detectedFunctionName);
}
+ }
- const toolCall = delta.tool_calls[0];
+ // 4. 일반 텍스트 스트림 (output_text 등)
+ else if (event.type === 'response.output_text.delta') {
+ socket.emit(SocketEvent.STREAM, event.delta);
+ if (onDelta) onDelta(event.delta);
+ } else if (event.type === 'response.completed') {
+ usedTotalTokens += event.response.usage.total_tokens;
+ if (onDelta) onDelta(event.delta);
+ }
+ }
- if (toolCall.function?.name) {
- functionName = toolCall.function.name;
- // console.log('🎯 Function name detected:', functionName);
- }
+ // 모든 함수 호출 실행
+ console.log(functionCalls);
+ const functionResults = [];
+ for (const { functionName, functionArgsRaw } of functionCalls) {
+ const result = await handleFunctionCall(
+ functionName,
+ functionArgsRaw,
+ socket,
+ );
+
+ // 함수 실행 정보 추가
+ functionResults.push({
+ role: 'assistant',
+ content: `${functionName} 함수를 호출했습니다. 인자: ${functionArgsRaw}`,
+ });
- if (toolCall.function?.arguments) {
- functionArgsRaw += toolCall.function.arguments;
- // console.log('📝 Adding args chunk:', toolCall.function.arguments);
+ // searchPlans 함수의 경우 검색 결과 상세 정보 추가
+ console.log('여기야', functionName, result);
+ if (functionName === 'searchPlans' && result) {
+ if (result.result === 'empty') {
+ functionResults.push({
+ role: 'function',
+ name: functionName,
+ content: `검색 결과: 빈 배열 (조건에 맞는 요금제 없음)`,
+ });
+ } else if (result.result === 'found') {
+ functionResults.push({
+ role: 'function',
+ name: functionName,
+ content: `검색 결과: ${result.plansCount}개 요금제 발견 (${result.planNames?.join(', ')})`,
+ });
}
- continue;
+ } else {
+ functionResults.push({
+ role: 'user',
+ content: `${functionName} 함수가 성공적으로 실행되었습니다.`,
+ });
}
- // console.log('🔍 delta:', delta);
+ }
- // delta 구조 상세 확인
- if (delta.tool_calls) {
- console.log('✅ tool_calls 존재:', delta.tool_calls);
- }
- if (delta.function_call) {
- console.log('✅ function_call 존재:', delta.function_call);
- }
+ socket.emit(SocketEvent.DONE);
- // 일반 메시지 content
- const content = delta?.content;
- if (content) {
- accumulatedContent += content;
+ return {
+ hasFunctionCalls: functionCalls.length > 0,
+ functionResults: functionResults,
+ };
+ } catch (error) {
+ handleGPTError(error, socket);
+ return { hasFunctionCalls: false, functionResults: [] };
+ }
+};
- // 텍스트에서 function call 패턴 감지 (더 엄격한 패턴)
- const functionCallMatch = accumulatedContent.match(
- /functions?\.(\w+)\s*\(\s*\{([\s\S]*?)\}\s*\)\s*$/,
+/**
+ * 멀티턴 채팅 (function calling → 역질문 생성)
+ */
+export const streamChatWithFollowUp = async (messages, socket, onDelta) => {
+ try {
+ // 1단계: 기존 streamChat 사용하여 function call 여부 확인
+ const { hasFunctionCalls, functionResults } = await streamChat(
+ messages,
+ socket,
+ onDelta,
+ );
+
+ // 2단계: 특정 함수 호출 시에만 역질문 생성
+ if (hasFunctionCalls) {
+ // 역질문 대상 함수들
+ const followUpTargetFunctions = ['requestTextCard', 'searchPlans'];
+ console.log(functionResults);
+ // 실행된 함수들 중 역질문 대상이 있는지 확인
+ const executedFunctionNames = functionResults
+ .filter((result) => result.role === 'assistant')
+ .map((result) => {
+ const match = result.content.match(/^(\w+) 함수를 호출했습니다/);
+ return match ? match[1] : null;
+ })
+ .filter(Boolean);
+
+ const shouldGenerateFollowUp = executedFunctionNames.some((funcName) =>
+ followUpTargetFunctions.includes(funcName),
+ );
+
+ if (shouldGenerateFollowUp) {
+ console.log(
+ '🔄 Target functions detected, generating follow-up question',
);
+ console.log('📝 Executed functions:', executedFunctionNames);
+ // 역질문 생성을 위한 새로운 턴
+ await generateFollowUpQuestion(messages, functionResults, socket);
+ } else {
+ console.log('⏭️ No target functions, skipping follow-up question');
+ console.log('📝 Executed functions:', executedFunctionNames);
+ }
+ }
- if (functionCallMatch) {
- console.log(
- '🔍 Text-based function call detected:',
- functionCallMatch[0],
- );
+ console.log('🔄 Used total tokens:', usedTotalTokens);
+ } catch (error) {
+ handleGPTError(error, socket);
+ }
+};
- // function call 부분을 제거한 텍스트만 전송
- const cleanContent = accumulatedContent
- .replace(/functions?\.(\w+)\s*\(\s*\{[\s\S]*?}\s*\)\s*$/, '')
- .trim();
+/**
+ * 역질문 생성 (별도 턴)
+ */
+const generateFollowUpQuestion = async (
+ originalMessages,
+ functionResults,
+ socket,
+) => {
+ // 역질문 전용 메시지 구성 (기존 시스템 프롬프트 제외)
+ const userMessages = originalMessages.filter((msg) => msg.role !== 'system');
+
+ // 실행된 함수들 정보 추출
+ const executedFunctions = functionResults
+ .filter((result) => result.role === 'assistant')
+ .map((result) => result.content)
+ .join('\n');
+
+ // requestTextCard가 이미 실행되었는지 확인
+ const hasTextCardExecuted = functionResults.some(
+ (result) =>
+ result.role === 'assistant' && result.content.includes('requestTextCard'),
+ );
+
+ const followUpMessages = [
+ {
+ role: 'system',
+ content: `너는 요금제 추천 후 고객에게 추가 혜택을 안내하는 상담사야.
+
+**ImageCard(requestTextCard) 실행 확인:**
+${
+ hasTextCardExecuted
+ ? `- 이미 링크 정보가 제공되었으므로, 추가 부가서비스 링크는 보내지 않아야 함
+- 대신 "추천드린 요금제들을 참고해서 본인에게 맞는 요금제를 선택해보세요! 😊 추가 궁금한 점이 있으시면 언제든 말씀해주세요!" 같은 자연스러운 마무리 멘트로 대화를 정리해줘
+- 새로운 함수 호출은 하지 말고, 일반적인 텍스트 응답으로만 마무리하기`
+ : `- 아직 링크 정보가 제공되지 않았으므로, 아래 패턴에 따라 추가 혜택 질문을 진행해도 됨`
+}
+
+**검색 결과 확인 우선:**
+- 방금 searchPlans 함수가 빈 배열([])을 반환했다면, 조건에 맞는 요금제가 없다는 뜻이야
+- 이 경우 "조건에 맞는 요금제를 찾지 못했어요. 😅 다른 옵션을 확인해보시는 것은 어떨까요?"라고 안내하고 다음 중 하나를 제안해줘:
+
+**검색 결과 없음 시 대안 제시:**
+1. "예산을 조금 더 늘려서 찾아볼까요?" → requestCarouselButtons로 더 높은 가격대 옵션 제공
+2. "다른 통신 기술(5G/LTE)도 함께 살펴보시겠어요?" → requestOXCarouselButtons 호출
+3. "대신 인기 요금제들을 추천해드릴까요?" → requestCarouselButtons로 ["인기 요금제 보기", "조건 다시 설정", "상담원 연결"] 제공
+4. "조건을 다시 설정해서 찾아보시겠어요?" → requestCarouselButtons로 새로운 선택지 제공
+
+**검색 결과가 있는 경우에만 아래 추가 혜택 질문:**
+이미 요금제를 보여줬으니, 요금제 설명은 다시 하지 말고 추가 혜택 질문만 해줘:
+
+**중요: 질문 텍스트를 먼저 출력하고 그 다음에 함수 호출**
+
+**질문 예시들:**
+1. "혹시 가족 구성원 중 만 18세 이하의 청소년 자녀가 있으신가요? 있으시다면 추가 결합 혜택도 안내드릴게요!"
+ → 이 질문 텍스트를 먼저 출력한 후 requestOXCarouselButtons 호출
+
+2. "혹시 사용 중인 인터넷이 있으신가요? LG U+에서 500Mbps 이상 인터넷을 사용 중이시면 추가 할인을 받을 수 있어요!"
+ → 이 질문 텍스트를 먼저 출력한 후 requestOXCarouselButtons 호출
+
+3. "평소 한 달에 데이터를 얼마나 사용하시나요? 더 정확한 요금제를 추천드릴게요!"
+ → 이 질문 텍스트를 먼저 출력한 후 requestCarouselButtons 호출
+
+4. "평소 자주 시청하시는 OTT 서비스가 있으신가요? 요금제와 함께 이용하시면 더 저렴해질 수 있어요!"
+ → 이 질문 텍스트를 먼저 출력한 후 requestOTTServiceList 호출
+
+**절대 규칙:**
+- 요금제 정보는 절대 다시 설명하지 마
+- **매우 중요**: 반드시 질문 텍스트를 먼저 출력하고 그 다음에 함수 호출해야 함
+- 텍스트 없이 바로 함수만 호출하는 것은 절대 금지
+- "답변해주세요", "알려주세요" 같은 추가 멘트 금지
+- 검색 결과가 없으면 검색 결과 없음 대안 제시가 우선, 결과가 있으면 추가 혜택 질문
+
+**올바른 응답 형식:**
+1. 먼저 텍스트로 질문을 출력 (예: "혹시 가족 구성원 중 만 18세 이하의 청소년 자녀가 있으신가요?")
+2. 그 다음에 함수 호출 (예: requestOXCarouselButtons)
+
+**잘못된 예시 (금지):**
+- 텍스트 없이 바로 requestCarouselButtons 호출
+- 텍스트 없이 바로 requestOXCarouselButtons 호출
+- 텍스트 없이 바로 requestOTTServiceList 호출 `,
+ },
+ ...userMessages,
+ {
+ role: 'assistant',
+ content: '요금제를 확인해보세요.',
+ },
+ {
+ role: 'system',
+ content: `방금 실행된 함수들:
+${executedFunctions}
+
+🚨 중요: 무조건 아래 순서대로 해야 함:
+1. 먼저 텍스트로 질문 출력 (예: "혹시 가족분들과 함께 가입하시면 더 저렴해질 수 있는데, 관심 있으신가요?")
+2. 그 다음에 함수 호출 (예: requestOXCarouselButtons)
+
+텍스트 없이 바로 함수만 호출하는 것은 절대 금지. 반드시 텍스트 먼저 출력하고 함수 호출.`,
+ },
+ ];
+
+ // 역질문 전용 streamChat 호출 (FOLLOWUP_TOOLS 사용)
+ await streamChatForFollowUp(followUpMessages, socket, GPTConfig.MODEL_MINI);
+};
- // 스트리밍 종료 신호 먼저 전송
- socket.emit('done');
+/**
+ * 역질문 전용 스트림 채팅 (제한된 도구만 사용)
+ */
+const streamChatForFollowUp = async (messages, socket, model) => {
+ try {
+ const stream = await openai.responses.create({
+ model: model,
+ input: messages,
+ stream: true,
+ tool_choice: 'auto',
+ tools: FOLLOWUP_TOOLS, // 역질문 전용 도구만 사용
+ });
- // function call 실행
- isFunctionCalled = true;
- functionName = functionCallMatch[1];
+ // 함수 호출 정보 누적용
+ const functionCallMap = {}; // { [item_id]: { ... } }
+ const functionCalls = []; // 최종 실행용 배열
+ let hasTextContent = false; // 텍스트 응답이 있는지 확인
+ usedTotalTokens = 0;
+ for await (const event of stream) {
+ // 1. 함수 호출 item 추가
+ if (
+ event.type === 'response.output_item.added' &&
+ event.item.type === 'function_call'
+ ) {
+ functionCallMap[event.item.id] = {
+ ...event.item,
+ arguments: '',
+ };
+
+ const functionName = event.item.name;
+ socket.emit(SocketEvent.LOADING, {
+ type: LoadingType.SEARCHING,
+ functionName: functionName,
+ });
+ }
- // 텍스트 기반 function call 감지 시 로딩 시작
- socket.emit('loading', {
- type: functionName.includes('Plan') ? 'dbcalling' : 'searching',
- functionName: functionName,
- });
- console.log('🔄 텍스트 기반 로딩 시작:', functionName);
-
- try {
- functionArgsRaw = `{${functionCallMatch[2]}}`;
- } catch (e) {
- console.error('❌ Failed to parse function args from text:', e);
- }
-
- break; // 스트리밍 종료
- } else {
- // function call이 시작되는 패턴 감지 (전송 중단)
- if (
- accumulatedContent.includes('functions.') ||
- accumulatedContent.includes('function.')
- ) {
- // function call이 완성되기를 기다리므로 전송하지 않음
- // console.log(
- // '🔍 Function call 시작 감지, 스트리밍 중단:',
- // accumulatedContent.substring(
- // accumulatedContent.lastIndexOf('function'),
- // ),
- // );
- } else {
- // "functions" 또는 "function" 단어만 있는 경우 체크
- if (
- accumulatedContent.includes(' functions') ||
- accumulatedContent.includes(' function') ||
- accumulatedContent.endsWith('functions') ||
- accumulatedContent.endsWith('function')
- ) {
- // 다음 청크를 기다려서 완전한 function call인지 확인
- console.log('🔍 Function 키워드 감지, 다음 청크 대기 중...');
- } else {
- // 정상 텍스트 전송
- socket.emit('stream', content);
- onDelta?.(content);
- }
- }
+ // 2. arguments 조각 누적
+ else if (event.type === 'response.function_call_arguments.delta') {
+ const id = event.item_id;
+ if (functionCallMap[id]) {
+ functionCallMap[id].arguments += event.delta;
}
}
- }
- if (isFunctionCalled) {
- try {
- console.log('🔧 Function called:', functionName);
- console.log('📄 Raw arguments:', functionArgsRaw);
-
- // 로딩 시작은 이미 tool_calls 감지 시 처리됨 (제거)
-
- let args = {};
- if (functionArgsRaw) {
- try {
- // JavaScript 객체 형식을 JSON으로 변환 (더 정교한 변환)
- let fixedJson = functionArgsRaw
- // 1. 키에 따옴표 추가 (단어로 시작하는 키들만)
- .replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":')
- // 2. 작은따옴표를 큰따옴표로 변환
- .replace(/'/g, '"')
- // 3. 숫자 뒤의 불필요한 소수점 제거 (-1.0 → -1)
- .replace(/(-?\d+)\.0(?=[,\s\]\}])/g, '$1')
- // 4. 줄바꿈과 연속된 공백 정리
- .replace(/\n\s*/g, ' ')
- .replace(/\s+/g, ' ')
- .trim();
-
- console.log(
- '🔄 변환 시도 (처음 200자):',
- fixedJson.substring(0, 200) + '...',
- );
- args = JSON.parse(fixedJson);
- console.log('✅ JavaScript 객체 → JSON 변환 성공');
- } catch (secondParseError) {
- // 더 강력한 방법: eval 사용 (보안상 주의 필요하지만 서버에서만 사용)
- try {
- console.warn('🔄 eval 방식으로 재시도...');
- args = eval(`(${functionArgsRaw})`);
- console.log('✅ eval 방식으로 변환 성공');
- } catch (evalError) {
- console.error('❌ 최종 JSON 파싱 실패:', secondParseError);
- console.error('❌ eval 방식도 실패:', evalError);
- console.log('🔍 원본:', functionArgsRaw);
- console.log('🔍 변환 시도:', fixedJson);
-
- // 로딩 종료
- socket.emit('loading-end');
-
- // JSON 파싱 실패 에러를 클라이언트에게 전송
- socket.emit('error', {
- type: 'FUNCTION_ARGS_PARSE_ERROR',
- message: 'Function arguments 파싱에 실패했습니다.',
- details: {
- functionName,
- rawArgs: functionArgsRaw,
- parseError: secondParseError.message,
- },
- });
- return;
- }
- }
+ // 3. arguments 누적 완료(함수 호출 하나 완성)
+ else if (event.type === 'response.function_call_arguments.done') {
+ const id = event.item_id;
+ const call = functionCallMap[id];
+ if (call) {
+ functionCalls.push({
+ functionName: call.name,
+ functionArgsRaw: call.arguments,
+ });
}
+ }
- switch (functionName) {
- case 'requestOTTServiceList': {
- // 새로 추가: function call 정보 수집
- const functionCallInfo = {
- name: functionName,
- args: args || {},
- };
-
- // onFunctionCall 콜백이 있으면 호출 (안전하게)
- if (onFunctionCall && typeof onFunctionCall === 'function') {
- try {
- onFunctionCall(functionCallInfo);
- } catch (callbackError) {
- console.error('❌ onFunctionCall error:', callbackError);
- }
- }
-
- socket.emit('loading-end');
- socket.emit('ott-service-list', {
- question: '어떤 OTT 서비스를 함께 사용 중이신가요?',
- options: ['넷플릭스', '디즈니+', '티빙', '왓챠'],
- });
- break;
- }
-
- case 'requestOXCarouselButtons': {
- // 새로 추가: function call 정보 수집
- const functionCallInfo = {
- name: functionName,
- args: args || {},
- };
-
- // onFunctionCall 콜백이 있으면 호출 (안전하게)
- if (onFunctionCall && typeof onFunctionCall === 'function') {
- try {
- onFunctionCall(functionCallInfo);
- } catch (callbackError) {
- console.error('❌ onFunctionCall error:', callbackError);
- }
- }
-
- socket.emit('loading-end');
- socket.emit('ox-carousel-buttons', {
- options: ['예', '아니오'],
- });
- break;
- }
-
- case 'requestCarouselButtons': {
- const { items } = args;
- if (!items) {
- socket.emit('loading-end');
- socket.emit('error', {
- type: 'MISSING_FUNCTION_ARGS',
- message: 'requestCarouselButtons에 필요한 items가 없습니다.',
- details: { functionName, args },
- });
- return;
- }
-
- // 새로 추가: function call 정보 수집
- const functionCallInfo = {
- name: functionName,
- args: { items },
- };
-
- // onFunctionCall 콜백이 있으면 호출 (안전하게)
- if (onFunctionCall && typeof onFunctionCall === 'function') {
- try {
- onFunctionCall(functionCallInfo);
- } catch (callbackError) {
- console.error('❌ onFunctionCall error:', callbackError);
- }
- }
-
- socket.emit('loading-end');
- socket.emit('carousel-buttons', items);
- break;
- }
-
- case 'showPlanLists': {
- const { plans } = args;
- if (!plans) {
- socket.emit('loading-end');
- socket.emit('error', {
- type: 'MISSING_FUNCTION_ARGS',
- message: 'showPlanLists에 필요한 plans가 없습니다.',
- details: { functionName, args },
- });
- return;
- }
-
- // 새로 추가: function call 정보 수집
- const functionCallInfo = {
- name: functionName,
- args: { plans },
- };
-
- // onFunctionCall 콜백이 있으면 호출 (안전하게)
- if (onFunctionCall && typeof onFunctionCall === 'function') {
- try {
- onFunctionCall(functionCallInfo);
- } catch (callbackError) {
- console.error('❌ onFunctionCall error:', callbackError);
- }
- }
-
- socket.emit('loading-end');
- socket.emit('plan-lists', plans);
- break;
- }
-
- case 'requestTextCard': {
- const { title, description, url, buttonText, imageUrl } = args;
- if (!title || !description || !url || !buttonText) {
- socket.emit('loading-end');
- socket.emit('error', {
- type: 'MISSING_FUNCTION_ARGS',
- message:
- 'requestTextCard에 필요한 title, description, url, buttonText가 없습니다.',
- details: { functionName, args },
- });
- return;
- }
-
- // 새로 추가: function call 정보 수집
- const functionCallInfo = {
- name: functionName,
- args: { title, description, url, buttonText, imageUrl },
- };
-
- // onFunctionCall 콜백이 있으면 호출 (안전하게)
- if (onFunctionCall && typeof onFunctionCall === 'function') {
- try {
- onFunctionCall(functionCallInfo);
- } catch (callbackError) {
- console.error('❌ onFunctionCall error:', callbackError);
- }
- }
-
- socket.emit('loading-end');
-
- // imageUrl이 없으면 URL에서 메타데이터 추출
- let finalImageUrl = imageUrl;
- if (!finalImageUrl) {
- console.log('🔍 URL에서 메타데이터 추출 중:', url);
- finalImageUrl = await extractMetadata(url);
- console.log('📸 추출된 이미지 URL:', finalImageUrl);
- }
-
- socket.emit('text-card', {
- title,
- description,
- url,
- buttonText,
- imageUrl: finalImageUrl,
- });
- break;
- }
-
- case 'showFirstCardList': {
- // 새로 추가: function call 정보 수집
- const functionCallInfo = {
- name: functionName,
- args: args || {},
- };
-
- // onFunctionCall 콜백이 있으면 호출 (안전하게)
- if (onFunctionCall && typeof onFunctionCall === 'function') {
- try {
- onFunctionCall(functionCallInfo);
- } catch (callbackError) {
- console.error('❌ onFunctionCall error:', callbackError);
- }
- }
-
- socket.emit('loading-end');
- socket.emit('first-card-list');
- break;
- }
-
- default:
- socket.emit('loading-end');
- socket.emit('error', {
- type: 'UNKNOWN_FUNCTION',
- message: `알 수 없는 function: ${functionName}`,
- details: { functionName, args },
- });
- }
- } catch (functionError) {
- console.error(
- `Function call 처리 실패 (${functionName}):`,
- functionError,
- );
- socket.emit('loading-end');
- socket.emit('error', {
- type: 'FUNCTION_EXECUTION_ERROR',
- message: '기능 처리 중 오류가 발생했습니다.',
- details: {
- functionName,
- args,
- error: functionError.message,
- },
- });
+ // 4. 일반 텍스트 스트림 (output_text 등) - 역질문 전용 스트림 사용
+ else if (event.type === 'response.output_text.delta') {
+ hasTextContent = true;
+ socket.emit(SocketEvent.FOLLOWUP_STREAM, event.delta);
+ } else if (event.type === 'response.completed') {
+ usedTotalTokens += event.response.usage.total_tokens;
}
}
- // function call이 처리되지 않은 경우에만 done 신호 전송
- if (!isFunctionCalled) {
- // function call 시작 패턴이 있지만 완성되지 않은 경우 처리
- if (
- accumulatedContent.includes('functions.') ||
- accumulatedContent.includes('function.') ||
- accumulatedContent.includes(' functions') ||
- accumulatedContent.includes(' function') ||
- accumulatedContent.endsWith('functions') ||
- accumulatedContent.endsWith('function')
- ) {
- console.warn(
- '⚠️ 불완전한 function call 감지:',
- accumulatedContent.substring(
- Math.max(0, accumulatedContent.lastIndexOf('function') - 20),
- ),
- );
+ // 역질문 함수 호출 실행
+ console.log('Has text content:', hasTextContent);
- // 불완전한 function call 부분 제거 후 전송
- const cleanedContent = accumulatedContent
- .replace(/\s*functions?\s*$/, '')
- .replace(/\s*function\s*$/, '')
- .trim();
-
- if (cleanedContent) {
- socket.emit('stream', cleanedContent);
- }
- }
-
- socket.emit('done');
+ for (const { functionName, functionArgsRaw } of functionCalls) {
+ await handleFunctionCall(functionName, functionArgsRaw, socket);
}
+
+ socket.emit(SocketEvent.DONE);
} catch (error) {
- console.error('❌ GPT Service Error:', error);
-
- // 타임아웃 에러
- if (error.message === 'REQUEST_TIMEOUT') {
- socket.emit('error', {
- type: 'REQUEST_TIMEOUT',
- message: '⏱️ 응답 시간이 초과되었습니다. 다시 시도해주세요.',
- details: {
- timeout: '30초',
- message: error.message,
- },
- });
- }
- // OpenAI API 관련 에러
- else if (error.response) {
- socket.emit('error', {
- type: 'OPENAI_API_ERROR',
- message: 'AI 서비스 연결에 문제가 발생했습니다.',
- details: {
- status: error.response.status,
- statusText: error.response.statusText,
- message: error.message,
- },
- });
- }
- // 네트워크 에러
- else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
- socket.emit('error', {
- type: 'NETWORK_ERROR',
- message: '네트워크 연결에 문제가 발생했습니다.',
- details: {
- code: error.code,
- message: error.message,
- },
- });
- }
- // 스트리밍 에러
- else if (error.name === 'AbortError') {
- socket.emit('error', {
- type: 'STREAM_ABORTED',
- message: '스트리밍이 중단되었습니다.',
- details: {
- message: error.message,
- },
- });
- }
- // 기타 에러
- else {
- socket.emit('error', {
- type: 'UNKNOWN_ERROR',
- message: '예상치 못한 오류가 발생했습니다.',
- details: {
- message: error.message,
- stack: error.stack,
- },
- });
- }
+ handleGPTError(error, socket);
}
};
diff --git a/server/services/gptToolDefinitions.js b/server/services/gptToolDefinitions.js
new file mode 100644
index 0000000..f2c9f2d
--- /dev/null
+++ b/server/services/gptToolDefinitions.js
@@ -0,0 +1,249 @@
+export const GPT_TOOLS = [
+ {
+ type: 'function',
+ name: 'requestOTTServiceList',
+ description:
+ '유저에게 통신사와 연결된 OTT 서비스 목록을 선택하도록 응답 받습니다.',
+ parameters: {
+ type: 'object',
+ properties: {},
+ required: [],
+ additionalProperties: false,
+ },
+ },
+ {
+ type: 'function',
+ name: 'requestOXCarouselButtons',
+ description:
+ '유저에게 예/아니오로만 대답할 수 있는 선택지를 캐러셀 형태로 제공합니다.',
+ parameters: {
+ type: 'object',
+ properties: {},
+ required: [],
+ additionalProperties: false,
+ },
+ },
+ {
+ type: 'function',
+ name: 'requestCarouselButtons',
+ description:
+ '유저에게 짧은 키워드나 명사형 선택지를 가로 스크롤 캐러셀 형태로 제공합니다. 통신사명, 요금대, 데이터량, 기술(5G/LTE) 등 단순한 카테고리 선택에 사용합니다.',
+ parameters: {
+ type: 'object',
+ properties: {
+ items: {
+ type: 'array',
+ description: '캐러셀 버튼으로 보여줄 항목 리스트',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: '항목 고유 ID 또는 태그' },
+ label: { type: 'string', description: '버튼에 보여질 텍스트' },
+ },
+ required: ['id', 'label'],
+ additionalProperties: false,
+ },
+ },
+ },
+ required: ['items'],
+ additionalProperties: false,
+ },
+ },
+ {
+ type: 'function',
+ name: 'searchPlans',
+ description:
+ 'MongoDB에서 조건에 맞는 요금제를 조회하여 최대 3개까지 추천합니다. 사용자 요구사항에 맞는 요금제를 동적으로 검색할 때 사용합니다.',
+ parameters: {
+ type: 'object',
+ properties: {
+ category: {
+ type: 'string',
+ description: '요금제 카테고리 (5G, LTE)',
+ enum: ['5G', 'LTE'],
+ },
+ maxMonthlyFee: {
+ type: 'number',
+ description: '최대 월 요금 (원)',
+ },
+ minMonthlyFee: {
+ type: 'number',
+ description: '최소 월 요금 (원)',
+ },
+ minDataGb: {
+ type: 'number',
+ description: '최소 데이터량 (GB, -1은 무제한)',
+ },
+ ageGroup: {
+ type: 'string',
+ description: '대상 연령대',
+ enum: ['ALL', 'YOUTH', 'SENIOR', 'STUDENT', 'SOLDIER'],
+ },
+ isPopular: {
+ type: 'boolean',
+ description: '인기 요금제만 조회할지 여부',
+ },
+ preferredAddons: {
+ type: 'array',
+ description:
+ '선호하는 부가서비스 키워드 (예: ["NETFLIX", "DISNEY", "TVING", "MUSIC", "YOUTUBE", "BOOK", "KIDS", "UPLAY"])',
+ items: {
+ type: 'string',
+ enum: [
+ 'NETFLIX',
+ 'DISNEY',
+ 'DISNEY+',
+ 'TVING',
+ '티빙',
+ 'MUSIC',
+ '음악',
+ 'YOUTUBE',
+ '유튜브',
+ 'BOOK',
+ '책',
+ '독서',
+ 'KIDS',
+ '아이',
+ '어린이',
+ 'UPLAY',
+ '유플레이',
+ 'MEDIA',
+ '미디어',
+ 'PREMIUM',
+ '프리미엄',
+ ],
+ },
+ },
+ limit: {
+ type: 'number',
+ description: '조회할 최대 개수 (기본값: 3)',
+ default: 3,
+ },
+ },
+ required: [],
+ additionalProperties: false,
+ },
+ },
+ {
+ type: 'function',
+ name: 'showPlanLists',
+ description:
+ '유저에게 여러 요금제 상세 정보를 카드 형식으로 제공합니다. 보통 3개 이상의 요금제를 추천할 때 사용합니다.',
+ parameters: {
+ type: 'object',
+ properties: {
+ plans: {
+ type: 'array',
+ description: '추천할 요금제 목록',
+ items: {
+ type: 'object',
+ properties: {
+ _id: { type: 'string', description: '요금제 고유 ID' },
+ category: {
+ type: 'string',
+ description: '요금제 카테고리 (5G, LTE 등)',
+ },
+ name: { type: 'string', description: '요금제 이름' },
+ description: { type: 'string', description: '요금제 설명' },
+ isPopular: { type: 'boolean', description: '인기 요금제 여부' },
+ dataGb: {
+ type: 'number',
+ description: '기본 데이터 제공량 (-1은 무제한)',
+ },
+ sharedDataGb: {
+ type: 'number',
+ description: '공유/테더링 데이터 (GB)',
+ },
+ voiceMinutes: {
+ type: 'number',
+ description: '음성통화 시간 (-1은 무제한)',
+ },
+ addonVoiceMinutes: {
+ type: 'number',
+ description: '추가 음성통화 시간',
+ },
+ smsCount: {
+ type: 'number',
+ description: 'SMS 개수 (-1은 무제한)',
+ },
+ monthlyFee: { type: 'number', description: '월 요금' },
+ optionalDiscountAmount: {
+ type: 'number',
+ description: '최대 할인 가능 금액',
+ },
+ ageGroup: {
+ type: 'string',
+ description: '대상 연령대 (ALL, YOUTH 등)',
+ },
+ detailUrl: {
+ type: 'string',
+ description: '자세히 보기 링크 URL',
+ },
+ bundleBenefit: {
+ type: ['string', 'null'],
+ description: '결합 할인 정보',
+ },
+ mediaAddons: {
+ type: ['string', 'null'],
+ description: '미디어 부가서비스',
+ },
+ premiumAddons: {
+ type: ['string', 'null'],
+ description: '프리미엄 부가서비스',
+ },
+ basicService: { type: 'string', description: '기본 제공 서비스' },
+ },
+ required: [
+ '_id',
+ 'category',
+ 'name',
+ 'description',
+ 'isPopular',
+ 'dataGb',
+ 'sharedDataGb',
+ 'voiceMinutes',
+ 'addonVoiceMinutes',
+ 'smsCount',
+ 'monthlyFee',
+ 'optionalDiscountAmount',
+ 'ageGroup',
+ 'detailUrl',
+ 'basicService',
+ ],
+ additionalProperties: false,
+ },
+ },
+ },
+ required: ['plans'],
+ additionalProperties: false,
+ },
+ },
+ {
+ type: 'function',
+ name: 'requestTextCard',
+ description:
+ '유저에게 특정 웹사이트나 링크로 안내할 때 사용합니다. URL의 미리보기 이미지와 함께 카드 형태로 보여줍니다. 유플러스 사이트나 추천하는 외부 링크를 안내할 때 사용합니다.',
+ parameters: {
+ type: 'object',
+ properties: {
+ title: { type: 'string', description: '카드에 표시될 제목' },
+ description: {
+ type: 'string',
+ description: '카드에 표시될 설명 텍스트',
+ },
+ url: { type: 'string', description: '안내할 링크 URL' },
+ buttonText: {
+ type: 'string',
+ description:
+ '버튼에 표시될 텍스트 (예: "자세히 보기", "사이트 방문하기")',
+ },
+ imageUrl: {
+ type: 'string',
+ description: '카드에 표시될 이미지 URL (선택사항)',
+ },
+ },
+ required: ['title', 'description', 'url', 'buttonText'],
+ additionalProperties: false,
+ },
+ },
+];
diff --git a/server/socket/socket.js b/server/socket/socket.js
index ac8fd35..f7833bc 100644
--- a/server/socket/socket.js
+++ b/server/socket/socket.js
@@ -1,10 +1,7 @@
import { Server } from 'socket.io';
import { v4 as uuidv4 } from 'uuid';
import { handlePlanRecommend } from '../controllers/planSocketController.js';
-import {
- emitRecommendReasonByGuide,
- getPlanIds,
-} from '../services/gptFuncCallTest.js';
+import { ChatSession } from '../models/ChatSession.js';
import { conditionByPlanGuide, InputRoleEnum } from '../utils/constants.js';
export const setupSocket = (server) => {
@@ -25,23 +22,10 @@ export const setupSocket = (server) => {
});
// 기본 대화
- socket.on('recommend-plan', (userInput) => {
+ socket.on('chat', (userInput) => {
handlePlanRecommend(socket, userInput);
});
- // 제거: 로컬스토리지로 마이그레이션으로 인해 MongoDB 저장 불필요
- // socket.on('carousel-selection', (selectionData) => {
- // handleCarouselSelection(socket, selectionData);
- // });
-
- // socket.on('update-carousel-selection', (updateData) => {
- // handleUpdateCarouselSelection(socket, updateData);
- // });
-
- // socket.on('update-ott-selection', (updateData) => {
- // handleUpdateOttSelection(socket, updateData);
- // });
-
/** 가이드 별 적절한 요금제를 추천 */
socket.on('recommend-plan-by-guide', async (message) => {
console.log('recommend-plan-by-guide >>', message);
diff --git a/server/utils/constants.js b/server/utils/constants.js
index 913446e..bedeece 100644
--- a/server/utils/constants.js
+++ b/server/utils/constants.js
@@ -66,3 +66,47 @@ export const InputRoleEnum = {
SYSTEM: 'system',
ASSISTANT: 'assistant',
};
+
+export const GPTConfig = {
+ MODEL: 'gpt-4.1-mini',
+ MODEL_MINI: 'gpt-4o-mini',
+ TIMEOUT_MS: 30000,
+ MAX_REDIRECTS: 5,
+ REQUEST_TIMEOUT: 10000,
+};
+
+export const LoadingType = {
+ SEARCHING: 'searching',
+ DB_CALLING: 'dbcalling',
+};
+
+export const ErrorType = {
+ REQUEST_TIMEOUT: 'REQUEST_TIMEOUT',
+ OPENAI_API_ERROR: 'OPENAI_API_ERROR',
+ NETWORK_ERROR: 'NETWORK_ERROR',
+ STREAM_ABORTED: 'STREAM_ABORTED',
+ FUNCTION_ARGS_PARSE_ERROR: 'FUNCTION_ARGS_PARSE_ERROR',
+ MISSING_FUNCTION_ARGS: 'MISSING_FUNCTION_ARGS',
+ UNKNOWN_FUNCTION: 'UNKNOWN_FUNCTION',
+ FUNCTION_EXECUTION_ERROR: 'FUNCTION_EXECUTION_ERROR',
+ UNKNOWN_ERROR: 'UNKNOWN_ERROR',
+};
+
+export const SocketEvent = {
+ STREAM: 'stream',
+ FOLLOWUP_STREAM: 'follow-up-stream',
+ DONE: 'done',
+ LOADING: 'loading',
+ LOADING_END: 'loading-end',
+ ERROR: 'error',
+ OTT_SERVICE_LIST: 'ott-service-list',
+ OX_CAROUSEL_BUTTONS: 'ox-carousel-buttons',
+ CAROUSEL_BUTTONS: 'carousel-buttons',
+ PLAN_LISTS: 'plan-lists',
+ TEXT_CARD: 'text-card',
+ FIRST_CARD_LIST: 'first-card-list',
+};
+
+export const OTTServices = ['넷플릭스', '디즈니+', '티빙', '왓챠'];
+
+export const OXOptions = ['예', '아니오'];
diff --git a/server/utils/gptTools.js b/server/utils/gptTools.js
deleted file mode 100644
index c30b757..0000000
--- a/server/utils/gptTools.js
+++ /dev/null
@@ -1,54 +0,0 @@
-/** 기본 plan tool 정의
- * @note name, description 정의 필요
- */
-export const BASE_PLAN_TOOL_DEF = {
- strict: true,
- type: 'function',
- parameters: {
- type: 'object',
- additionalProperties: false,
- properties: {
- plans: {
- type: 'array',
- items: {
- type: 'object',
- additionalProperties: false,
- properties: {
- _id: { type: 'string' },
- name: { type: 'string' },
- description: { type: 'string' },
- dataGb: {
- type: 'number',
- description: '기본 데이터',
- },
- sharedDataGb: { type: 'number', description: '공유 데이터' },
- monthlyFee: { type: 'number', description: '월정액' },
- detailUrl: { type: 'string', description: '상세페이지 링크' },
- bundleBenefit: {
- type: 'object',
- description: '결합 혜택 정보',
- additionalProperties: false,
- properties: {
- _id: { type: 'string' },
- name: { type: 'string' },
- description: { type: 'string' },
- },
- required: ['_id', 'name', 'description'],
- },
- },
- required: [
- '_id',
- 'name',
- 'description',
- 'dataGb',
- 'sharedDataGb',
- 'monthlyFee',
- 'detailUrl',
- 'bundleBenefit',
- ],
- },
- },
- },
- required: ['plans'],
- },
-};
diff --git a/server/utils/metadataExtractor.js b/server/utils/metadataExtractor.js
new file mode 100644
index 0000000..f1d3f2c
--- /dev/null
+++ b/server/utils/metadataExtractor.js
@@ -0,0 +1,51 @@
+import axios from 'axios';
+import * as cheerio from 'cheerio';
+import { GPTConfig } from './constants.js';
+
+/**
+ * URL에서 메타데이터를 추출하여 이미지 URL을 반환합니다.
+ * @param {string} url - 메타데이터를 추출할 URL
+ * @returns {Promise
} 추출된 이미지 URL 또는 null
+ */
+export const extractMetadata = async (url) => {
+ try {
+ const response = await axios.get(url, {
+ timeout: GPTConfig.REQUEST_TIMEOUT,
+ headers: {
+ 'User-Agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
+ },
+ maxRedirects: GPTConfig.MAX_REDIRECTS,
+ });
+
+ const html = response.data;
+ const $ = cheerio.load(html);
+
+ const getMetaContent = (selector) => {
+ const element = $(selector);
+ return element.attr('content') || element.text() || null;
+ };
+
+ let imageUrl =
+ getMetaContent('meta[property="og:image"]') ||
+ getMetaContent('meta[name="twitter:image"]') ||
+ null;
+
+ // 상대 URL을 절대 URL로 변환
+ if (imageUrl && !imageUrl.startsWith('http')) {
+ const validUrl = new URL(url);
+ if (imageUrl.startsWith('//')) {
+ imageUrl = validUrl.protocol + imageUrl;
+ } else if (imageUrl.startsWith('/')) {
+ imageUrl = validUrl.origin + imageUrl;
+ } else {
+ imageUrl = validUrl.origin + '/' + imageUrl;
+ }
+ }
+
+ return imageUrl;
+ } catch (error) {
+ console.warn('메타데이터 추출 실패:', error.message);
+ return null;
+ }
+};
diff --git a/server/utils/promptBuilder.js b/server/utils/promptBuilder.js
index 5ad13ca..0467134 100644
--- a/server/utils/promptBuilder.js
+++ b/server/utils/promptBuilder.js
@@ -1,282 +1,223 @@
export const buildPromptMessages = (plans, fullMessages) => {
const systemMessage = {
role: 'system',
- content: `너는 LG유플러스 요금제 추천 도우미야! 📱✨ 반드시 간단한 인사와 요금제 추천과 관련된 질문에만 응답해야 해. 요금제 외의 질문(예: 요리 레시피, 날씨, 일반 상식 등)은 답변하지 말고 "저는 요금제 추천 도우미입니다. 📱💡" 라면서 요금제 추천에 관심이 있냐고 유저에게 친절하게 안내해.
+ content: `너는 LG유플러스 요금제 추천 도우미야! 반드시 간단한 인사와 요금제 추천과 관련된 질문에만 응답해야 해. 요금제 외의 질문(예: 요리 레시피, 날씨, 일반 상식 등)은 답변하지 말고 "저는 요금제 추천 도우미입니다. 📱💡" 라면서 요금제 추천에 관심이 있냐고 유저에게 친절하게 안내해.
-🤖 **Function Calling 활용 가이드**
+**Function Calling 활용 가이드**
아래 5가지 함수들을 적절한 상황에 맞춰 호출해야 해. 이 함수들은 유저가 일일이 타이핑하는 수고를 덜어주고 빠른 선택을 도와주기 위한 것이야!
-✨ **함수 호출 우선 원칙**: 직접 응답보다는 함수 호출을 통해 사용자 경험을 향상시켜야 해.
+ **함수 호출 우선 원칙**: 직접 응답보다는 함수 호출을 통해 사용자 경험을 향상시켜야 해.
+
+ **Function Calling 목록** (총 6개):
+
+1. **searchPlans**: 사용자가 요구하는 조건에 맞는 요금제를 MongoDB에서 검색해서 추천할 때 사용해. 카테고리(5G/LTE), 최대월요금, 최소데이터량, 연령대, 인기여부 등의 조건을 설정할 수 있고, 자동으로 최대 3개까지 추천해줘.
+**중요**: 사용자로부터 **충분한 정보를 수집한 후에만** 호출해야 함! 최소한 다음 중 2-3개는 파악해야 함:
+- 선호하는 통신 기술 (5G/LTE)
+- 예산 범위 (월 요금)
+- 데이터 사용량 (무제한/일정 GB)
+- 연령대나 특별한 조건 (청소년, 학생, 시니어 등)
+
+2. **requestOTTServiceList**: 유저에게 OTT 서비스(넷플릭스, 디즈니+, 티빙 등) 중 어떤 것을 사용 중인지 버튼으로 물어봐야 할 때 사용해.
+**중요**: 반드시 질문 텍스트를 먼저 출력한 후 함수 호출!
+예: "어떤 OTT 서비스를 함께 사용 중이신가요? 🎬" → requestOTTServiceList 호출
+
+3. **requestOXCarouselButtons**: 유저에게 예/아니오로만 대답할 수 있는 간단한 질문을 캐러셀 버튼으로 제공할 때 사용해.
+**중요**: 반드시 질문 텍스트를 먼저 출력한 후 함수 호출!
+예: "가족 결합 할인에 관심 있으신가요? 👨👩👧👦" → requestOXCarouselButtons 호출
+
+4. **requestCarouselButtons**: 유저가 한눈에 선택할 수 있도록 여러 요금제나 기능 항목을 가로 스크롤 캐러셀 버튼 형태로 보여줄 때 사용해.
+**중요**: 반드시 캐러셀 버튼을 보내기 전에 안내 텍스트를 먼저 출력해야 함!
+예시: "어떤 데이터량이 필요하신가요? 📊" (텍스트 먼저) → 그 다음 requestCarouselButtons 호출
+
+5. **showPlanLists**: [사용하지 않음] 이 함수는 더 이상 직접 호출하지 않습니다. searchPlans 함수가 자동으로 처리합니다.
+
+6. **requestTextCard**: 유저에게 특정 웹사이트나 링크로 안내할 때 사용해. URL의 미리보기 이미지와 함께 카드 형태로 보여줘. 유플러스 사이트나 추천하는 외부 링크를 안내할 때 사용해. (예: "자세한 내용은 공식 사이트에서 확인하세요", "더 많은 혜택 정보 보기" 등)
+
+ **요금제 추천 시 응답 패턴**:
+
+**1단계: 정보 수집 우선**
+사용자가 막연하게 "요금제 추천해줘"라고 하면, 바로 searchPlans를 호출하지 말고 **필수 정보를 먼저 수집**해야 함:
+
+- "어떤 통신 기술을 선호하시나요?" → requestCarouselButtons(["5G", "LTE", "상관없음"])
+- "월 예산은 어느 정도로 생각하고 계신가요?" → requestCarouselButtons(["3-5만원", "5-7만원", "7-10만원", "10-15만원", "15만원 이상", "예산 무관"])
+- "평소 데이터를 얼마나 사용하시나요?" → requestCarouselButtons(["20GB 이하", "50GB 정도", "100GB 이상", "무제한 필요"])
+
+**2단계: 충분한 정보 확보 후 추천**
+위 정보 중 2-3개를 파악한 후에만 다음과 같이 응답:
+
+1. 친절하게 상황에 맞는 요금제 유형을 추천하고,
+2. 간단한 장점 설명을 아이콘과 함께 추가한 후,
+3. searchPlans 함수를 통해 관련 요금제를 추천하시오.
+
+**예시**:
+
+**❌ 잘못된 패턴 (정보 부족 시 바로 추천):**
+사용자: "요금제 추천해줘"
+→ 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 사용 시 주의사항:**
+- 사용자의 요구사항에 맞는 조건을 정확히 설정해야 해
+- category: "5G" 또는 "LTE" 중 선택
+- **금액 조건 (사용자 선택 기반):**
+ - maxMonthlyFee: 최대 월 요금 (숫자로 입력, 예: 80000)
+ - minMonthlyFee: 최소 월 요금 (숫자로 입력, 예: 50000)
+ - 🎯 **예산 범위 질문 활용**: 사용자가 예산을 명확히 말하지 않으면 캐러셀 버튼으로 직접 물어보기
+ - "월 예산은 어느 정도로 생각하고 계신가요?" → 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
+ - "예산 무관" → 금액 조건 생략
+- minDataGb: 최소 데이터량 (-1은 무제한, 숫자로 입력)
+- ageGroup: "YOUTH", "SENIOR", "STUDENT", "SOLDIER", "ALL" 중 선택
+- isPopular: true/false (인기 요금제만 필터링할지 여부)
+- **preferredAddons**: 선호하는 부가서비스 (예: ["NETFLIX", "DISNEY", "TVING", "MUSIC"])
+ - 사용자가 OTT 서비스나 음악 서비스를 언급하면 해당 키워드 포함
+ - 사용 가능한 키워드: "NETFLIX", "DISNEY", "TVING", "MUSIC", "YOUTUBE", "BOOK", "KIDS", "UPLAY", "MEDIA", "PREMIUM"
+ - 실제 데이터 매칭: 넷플릭스, 디즈니+, 티빙, 바이브/지니뮤직, 유튜브 프리미엄, 밀리의 서재, 아이들나라, 유플레이 등
+- limit: 조회할 개수 (기본값 3개, 최대 3개 권장)
-📱 **Function Calling 목록** (총 5개):
-
-1. 🎬 **requestOTTServiceList**: 유저에게 OTT 서비스(넷플릭스, 디즈니+, 티빙 등) 중 어떤 것을 사용 중인지 버튼으로 물어봐야 할 때 사용해.
-
-2. ⭕ **requestOXCarouselButtons**: 유저에게 예/아니오로만 대답할 수 있는 간단한 질문을 캐러셀 버튼으로 제공할 때 사용해. (예: "5G 요금제 원하시나요?", "가족 결합 할인 관심 있으신가요?" 등)
-
-3. 🎯 **requestCarouselButtons**: 유저가 한눈에 선택할 수 있도록 여러 요금제나 기능 항목을 가로 스크롤 캐러셀 버튼 형태로 보여줄 때 사용해. (예: 통신사명, 요금대, 데이터량, 기술 등)
-
-4. 📋 **showPlanLists**: 요금제 여러개(보통 3개 이상)에 대한 상세 정보를 카드 형식으로 보여줄 때 사용해. 이때 요금제 배열을 전달해야 하며, 각 요금제는 이름, 월 요금, 설명, 데이터 제공량, 음성 통화, 혜택 등을 포함해야 해. 반드시 실제 요금제 데이터를 그대로 전달해야 해.
-
-5. 🔗 **requestTextCard**: 유저에게 특정 웹사이트나 링크로 안내할 때 사용해. URL의 미리보기 이미지와 함께 카드 형태로 보여줘. 유플러스 사이트나 추천하는 외부 링크를 안내할 때 사용해. (예: "자세한 내용은 공식 사이트에서 확인하세요", "더 많은 혜택 정보 보기" 등)
+또는 상황에 따라 requestCarouselButtons, requestOXCarouselButtons, requestTextCard 함수로 선택지를 먼저 유도할 수도 있음.
-🎯 **요금제 추천 시 응답 패턴**:
-사용자의 상황이 구체적일 경우에는 다음과 같이 응답하시오:
+항상 친절하고 자연스럽게 응답한 후, 적절한 함수로 연결되도록 한다.
-1. 😊 친절하게 상황에 맞는 요금제 유형을 추천하고,
-2. 💡 간단한 장점 설명을 아이콘과 함께 추가한 후,
-3. 🚀 관련 요금제를 Function Calling을 통해 추천하시오.
+...예를 들어 '50,000원 이하 요금제 알려줘요'처럼 구체적인 사용 상황이 빠졌다면, '데이터 사용량은 얼마나 되시나요?' 같은 질문을 먼저 해도 좋아.
-🚨 **매우 중요한 규칙**:
+**역질문 패턴 (요금제 추천 후 필수 실행)**:
+searchPlans 함수를 호출한 후에는 반드시 아래 중 하나 이상의 역질문을 통해 사용자 경험을 개선해야 해:
+
+**역질문 우선순위**:
+0. **검색 결과 없음**: searchPlans 함수 호출 후 빈 배열([])이 반환되면, "조건에 맞는 요금제를 찾지 못했어요. 😅 검색 조건을 조금 완화해서 다른 요금제들을 살펴보시는 것은 어떨까요?"라고 안내한 후, 다음 중 하나를 제안해야 함:
+ - 예산 범위 확대: "예산을 조금 더 늘려서 찾아볼까요?" → requestCarouselButtons로 더 높은 가격대 옵션 제공
+ - 다른 통신기술 제안: "LTE 요금제도 함께 살펴보시겠어요?" → requestOXCarouselButtons 호출
+ - 인기 요금제 대안: "대신 인기 요금제들을 추천해드릴까요?" → searchPlans({ isPopular: true, limit: 3 }) 호출
+ - 조건 재설정: "다른 조건으로 다시 찾아보시겠어요?" → requestCarouselButtons로 새로운 선택지 제공
+
+1. **가족 결합 할인**: "가족분들과 함께 가입하시면 더 저렴하게 이용하실 수 있어요! 가족 결합 할인에 관심 있으신가요?" → requestOXCarouselButtons 호출
+ - 사용자가 "예" 선택 시: "U+ 투게더 결합에 대해 더 자세히 알아보시겠어요?" → requestTextCard 호출
+
+2. **OTT 서비스**: "혹시 넷플릭스, 디즈니+, 티빙 같은 OTT 서비스도 함께 이용하고 계신가요?" → requestOTTServiceList 호출
+ - OTT 서비스 선택 후: "선택하신 OTT 서비스와 함께 이용 가능한 요금제 혜택을 확인해보시겠어요?" → requestTextCard 호출
+
+3. **부가서비스**: "추가로 필요한 서비스가 있으실까요?" → requestCarouselButtons로 ["음성통화 추가", "데이터 추가", "해외로밍", "보안서비스", "자세히 보기"] 제공
+ - "자세히 보기" 선택 시: "유플러스 공식 부가서비스 페이지에서 다양한 옵션을 확인해보세요!" → requestTextCard 호출
+
+4. **상세 안내**: "더 자세한 혜택이나 가입 절차가 궁금하시다면 공식 사이트를 확인해보세요!" → requestTextCard로 유플러스 공식 사이트 안내
+ - title: "LG U+ 공식 홈페이지"
+ - description: "요금제 상세정보, 가입 절차, 혜택 안내를 확인하세요."
+ - 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 시그니처 가족할인"
+ - description: "만 18세 이하 자녀 휴대폰 1대 요금을 최대 33,000원 할인해드립니다."
+ - url: "https://www.lguplus.com/mobile/plan/mplan/5g-all/5g-unlimited/Z202205253"
+ - buttonText: "가족할인 자세히 보기"
+
+- **OTT 서비스 선택 후**: 사용자가 OTT 서비스를 선택하면 → "선택하신 OTT 서비스와 함께 이용 가능한 요금제 혜택을 확인해보시겠어요?" → requestTextCard로 관련 부가서비스 페이지 안내
+ - title: "부가서비스 및 OTT 혜택 안내"
+ - description: "넷플릭스, 디즈니+, 티빙 등 다양한 OTT 서비스를 요금제와 함께 저렴하게 이용하세요!"
+ - url: "https://www.lguplus.com/mobile/plan/addon"
+ - buttonText: "부가서비스 보기"
+
+- **부가서비스 관심 표현 시**: "추가 서비스가 필요하시다면 유플러스 공식 부가서비스 페이지에서 다양한 옵션을 확인해보세요!" → requestTextCard 호출
+ - title: "LG U+ 부가서비스 전체 보기"
+ - description: "음악, 영상, 보안, 생활편의 등 다양한 부가서비스를 한눈에 확인하고 선택하세요."
+ - url: "https://www.lguplus.com/mobile/plan/addon"
+ - buttonText: "부가서비스 둘러보기"
+
+**예시**:
+"위의 요금제들 어떠신가요? 😊
+
+추가로 더 저렴하게 이용하실 방법이 있어요! 가족분들과 함께 가입하시면 최대 2만원까지 할인받으실 수 있는데, 가족 결합 할인에 관심 있으신가요?"
+
+→ requestOXCarouselButtons 호출
+
+사용자가 "예" 선택 시:
+"U+ 투게더 결합에 대해 더 자세한 정보를 확인해보시겠어요?"
+
+→ requestTextCard 호출 (U+ 투게더 결합 페이지)
+
+**캐러셀 버튼 사용 시 필수 규칙**:
+- **모든 캐러셀 버튼 함수(requestCarouselButtons, requestOXCarouselButtons, requestOTTServiceList) 호출 전에 반드시 질문이나 안내 텍스트를 먼저 출력해야 함**
+- 텍스트 출력 후 즉시 함수 호출
+- **올바른 예시들:**
+ • "어떤 요금대를 원하시나요? 💰" → requestCarouselButtons(요금대 옵션들)
+ • "평소 데이터를 얼마나 사용하시나요? 📱" → requestCarouselButtons(데이터량 옵션들)
+ • "가족 결합 할인에 관심 있으신가요? 👨👩👧👦" → requestOXCarouselButtons 호출
+ • "어떤 OTT 서비스를 함께 사용 중이신가요? 🎬" → requestOTTServiceList 호출
+- **잘못된 예시:** 텍스트 없이 바로 함수 호출 ❌
+
+**매우 중요한 규칙**:
- 절대로 "functions.함수명(...)" 같은 코드를 텍스트로 응답하지 마!
- 사용자에게 함수 호출 코드를 보여주는 것은 금지!
- 반드시 실제 tool_call 기능만 사용해!
- 만약 버튼이나 선택지를 보여주고 싶다면, 텍스트 설명 후 바로 해당 도구를 호출해!
+- searchPlans 호출 후에는 반드시 역질문 패턴을 실행해야 함!
-💬 **예시**:
-
-사용자: "가족들이랑 함께 가입할 요금제를 추천해줘."
-→ AI 응답:
-"물론이죠! 😊 가족이 함께 쓰신다면, **가족 결합형 요금제**를 추천드리고 싶어요.
+**searchPlans 함수 사용 예시**:
-👨👩👧👦 **가족 결합의 장점은?**
-- 📉 **요금 할인**: 휴대폰 개수나 인터넷 약정 기간에 따라 **최대 수만 원까지** 월 요금이 할인돼요.
-- 📱 **다양한 조합 가능**: 휴대폰 + 인터넷, 휴대폰 여러 회선, IPTV까지 자유롭게 조합 가능해요.
+사용자: "5G 요금제 중에서 8만원 이하로 추천해줘" (명확한 예산 제한)
+→ searchPlans({ category: "5G", maxMonthlyFee: 80000, limit: 3 })
-아래 요금제를 확인해보세요!👇"
+사용자가 캐러셀 버튼에서 "5-7만원" 선택
+→ searchPlans({ category: "5G", minMonthlyFee: 50000, maxMonthlyFee: 70000, limit: 3 })
-→ 그리고 showPlanLists 함수를 사용해 관련 요금제 3개를 카드 형식으로 보여주기
+사용자가 캐러셀 버튼에서 "10-15만원" 선택 + 넷플릭스 언급
+→ searchPlans({ minMonthlyFee: 100000, maxMonthlyFee: 150000, preferredAddons: ["NETFLIX"], limit: 3 })
-**showPlanLists 사용 시 주의사항:**
-- 반드시 plans 배열에 3개 이상의 요금제를 포함해야 해
-- 각 요금제는 아래 요금제 목록에서 실제 데이터를 그대로 복사해서 전달해야 해
-- 데이터를 임의로 수정하거나 만들어내면 안 돼!
-- _id, category, name, description 등 모든 필드를 정확히 포함해야 해
+사용자가 캐러셀 버튼에서 "15만원 이상" 선택 + 5G 음악 서비스
+→ searchPlans({ category: "5G", minMonthlyFee: 150000, preferredAddons: ["MUSIC"], limit: 3 })
-또는 상황에 따라 requestCarouselButtons, requestOXCarouselButtons, requestTextCard 함수로 선택지를 먼저 유도할 수도 있음.
+사용자가 캐러셀 버튼에서 "예산 무관" 선택 + 5G 넷플릭스
+→ searchPlans({ category: "5G", preferredAddons: ["NETFLIX"], limit: 3 })
-항상 친절하고 자연스럽게 응답한 후, 적절한 함수로 연결되도록 한다.
+사용자: "청년 대상 무제한 데이터 요금제 알려줘" (예산 미언급 - 질문 필요)
+→ 먼저 예산 범위 캐러셀 버튼으로 질문 후 검색
-...예를 들어 '50,000원 이하 요금제 알려줘요'처럼 구체적인 사용 상황이 빠졌다면, '데이터 사용량은 얼마나 되시나요?' 같은 질문을 먼저 해도 좋아.
+**중요**: 더 이상 요금제 목록을 프롬프트에 포함하지 않습니다. searchPlans 함수가 MongoDB에서 실시간으로 조회합니다.
-또한, 아래의 function들을 적절한 상황에 맞춰 호출해야 해:
-
-아래는 사용 가능한 요금제 목록이야.
-각 요금제는 이름, 월 요금, 데이터량, 공유 데이터량, 연령 대상, 결합 혜택, 부가서비스 정보로 구성돼 있어.
-사용자의 질문에 따라 가장 적절한 요금제를 3개 이상 추천해줘.
-필요하다면 아래 참고자료(부가서비스 설명, 결합 혜택 설명)를 참고해도 돼.
-
-📦 요금제 목록:
-
-{
- "_id": "1",
- "category": "5G",
- "name": "5G 시그니처",
- "description": "U⁺5G 서비스와 프리미엄 혜택을 마음껏 즐기고, 가족과 공유할 수 있는 데이터까지 추가로 받는 5G 요금제",
- "isPopular": false,
- "dataGb": -1.0,
- "sharedDataGb": 120,
- "voiceMinutes": -1,
- "addonVoiceMinutes": 300,
- "smsCount": -1,
- "monthlyFee": 130000,
- "optionalDiscountAmount": 92250,
- "ageGroup": "ALL",
- "detailUrl": "https://www.lguplus.com/mobile/plan/mplan/5g-all/5g-unlimited/Z202205253",
- "bundleBenefit": "U+ 투게더 결합, 5G 시그니처 가족할인, 태블릿/스마트기기 월정액 할인, 프리미어 요금제 약정할인, 로밍 혜택 프로모션",
- "mediaAddons": "아이들나라 스탠다드+러닝, 바이브 앱+PC 음악감상, 유플레이, 밀리의 서재, 지니뮤직 앱+PC 음악감상",
- "premiumAddons": "폰교체 패스, 삼성팩, 티빙 이용권 할인, 디즈니+, 넷플릭스, 헬로렌탈구독, 일리커피구독, 우리집지킴이 Easy2+, 우리집돌봄이 Kids, 신한카드 Air, 유튜브 프리미엄 할인",
- "basicService": "U+ 모바일tv 기본 월정액 무료, U+멤버십 VVIP 등급 혜택"
- },
- {
- "_id": "2",
- "category": "5G",
- "name": "5G 프리미어 슈퍼",
- "description": "U⁺5G 서비스와 프리미엄 혜택을 마음껏 즐기고, 가족과 공유할 수 있는 데이터까지 추가로 받는 5G 요금제",
- "isPopular": false,
- "dataGb": -1.0,
- "sharedDataGb": 100,
- "voiceMinutes": -1,
- "addonVoiceMinutes": 300,
- "smsCount": -1,
- "monthlyFee": 115000,
- "optionalDiscountAmount": 81000,
- "ageGroup": "ALL",
- "detailUrl": "https://www.lguplus.com/mobile/plan/mplan/5g-all/5g-unlimited/Z202205251",
- "bundleBenefit": "U+ 투게더 결합, 태블릿/스마트기기 월정액 할인, 프리미어 요금제 약정할인, 로밍 혜택 프로모션",
- "mediaAddons": "아이들나라 스탠다드+러닝, 유플레이, 밀리의 서재, 지니뮤직 앱+PC 음악감상, 바이브 앱 음악감상",
- "premiumAddons": "폰교체 패스, 삼성팩, 티빙 이용권 할인, 디즈니+, 넷플릭스, 헬로렌탈구독, 일리커피구독, 우리집지킴이 Easy2+, 우리집돌봄이 Kids, 신한카드 Air, 유튜브 프리미엄 할인",
- "basicService": "U+ 모바일tv 기본 월정액 무료, U+멤버십 VVIP 등급 혜택"
- },
- {
- "_id": "3",
- "category": "5G",
- "name": "5G 프리미어 플러스",
- "description": "U⁺5G 서비스는 물론, 스마트 기기 2개와 다양한 콘텐츠까지 마음껏 이용할 수 있는 5G 요금제",
- "isPopular": false,
- "dataGb": -1.0,
- "sharedDataGb": 100,
- "voiceMinutes": -1,
- "addonVoiceMinutes": 300,
- "smsCount": -1,
- "monthlyFee": 105000,
- "optionalDiscountAmount": 73500,
- "ageGroup": "ALL",
- "detailUrl": "https://www.lguplus.com/mobile/plan/mplan/5g-all/5g-unlimited/Z202205252",
- "bundleBenefit": "U+ 투게더 결합, 태블릿/스마트기기 월정액 할인, 프리미어 요금제 약정할인, 로밍 혜택 프로모션",
- "mediaAddons": "아이들나라 스탠다드+러닝, 유플레이, 밀리의 서재, 지니뮤직 앱+PC 음악감상, 바이브 앱 음악감상",
- "premiumAddons": "폰교체 패스, 삼성팩, 티빙 이용권 할인, 넷플릭스, 디즈니+, 헬로렌탈구독, 일리커피구독, 우리집지킴이 Easy2+, 우리집돌봄이 Kids, 신한카드 Air, 유튜브 프리미엄 할인",
- "basicService": "U+ 모바일tv 기본 월정액 무료, U+멤버십 VVIP 등급 혜택"
- },
- {
- "_id": "4",
- "category": "5G",
- "name": "5G 프리미어 레귤러",
- "description": "U⁺5G 서비스는 물론, 스마트기기 1개와 다양한 콘텐츠까지 마음껏 이용할 수 있는 5G 요금제",
- "isPopular": true,
- "dataGb": -1.0,
- "sharedDataGb": 80,
- "voiceMinutes": -1,
- "addonVoiceMinutes": 300,
- "smsCount": -1,
- "monthlyFee": 95000,
- "optionalDiscountAmount": 66000,
- "ageGroup": "ALL",
- "detailUrl": "https://www.lguplus.com/mobile/plan/mplan/5g-all/5g-unlimited/LPZ0000433",
- "bundleBenefit": "U+ 투게더 결합, 태블릿/스마트기기 월정액 할인, 프리미어 요금제 약정할인, 로밍 혜택 프로모션",
- "mediaAddons": "아이들나라 스탠다드+러닝, 유플레이, 밀리의 서재, 바이브 300회 음악감상, 지니뮤직 300회 음악감상",
- "premiumAddons": null,
- "basicService": "U+ 모바일tv 기본 월정액 무료, U+멤버십 VVIP 등급 혜택"
- },
- {
- "_id": "5",
- "category": "5G",
- "name": "5G 프리미어 에센셜",
- "description": "U⁺5G 서비스를 마음껏 즐길 수 있는 5G 요금제",
- "isPopular": true,
- "dataGb": -1.0,
- "sharedDataGb": 70,
- "voiceMinutes": -1,
- "addonVoiceMinutes": 300,
- "smsCount": -1,
- "monthlyFee": 85000,
- "optionalDiscountAmount": 58500,
- "ageGroup": "ALL",
- "detailUrl": "https://www.lguplus.com/mobile/plan/mplan/5g-all/5g-unlimited/LPZ0000409",
- "bundleBenefit": "U+ 투게더 결합, 태블릿/스마트기기 월정액 할인, 프리미어 요금제 약정할인, 로밍 혜택 프로모션",
- "mediaAddons": null,
- "premiumAddons": null,
- "basicService": "U+ 모바일tv 기본 월정액 무료, U+멤버십 VIP 등급 혜택"
- },
- {
- "_id": "6",
- "category": "5G",
- "name": "5G 복지 75",
- "description": "복지할인 받는 고객님을 위한 5G 요금제",
- "isPopular": false,
- "dataGb": 150.0,
- "sharedDataGb": 60,
- "voiceMinutes": -1,
- "addonVoiceMinutes": 600,
- "smsCount": -1,
- "monthlyFee": 75000,
- "optionalDiscountAmount": 56250,
- "ageGroup": "ALL",
- "detailUrl": "https://www.lguplus.com/mobile/plan/mplan/5g-all/5g-welfare/LPZ0000348",
- "bundleBenefit": null,
- "mediaAddons": null,
- "premiumAddons": null,
- "basicService": "U+ 모바일tv 기본 월정액 무료, U+멤버십 VIP 등급 혜택"
- },
- {
- "_id": "7",
- "category": "5G",
- "name": "5G 스탠다드",
- "description": "넉넉한 데이터로 U⁺5G 서비스를 이용할 수 있는 5G 표준 요금제",
- "isPopular": true,
- "dataGb": 150.0,
- "sharedDataGb": 60,
- "voiceMinutes": -1,
- "addonVoiceMinutes": 300,
- "smsCount": -1,
- "monthlyFee": 75000,
- "optionalDiscountAmount": 56250,
- "ageGroup": "ALL",
- "detailUrl": "https://www.lguplus.com/mobile/plan/mplan/5g-all/5g-unlimited/LPZ0000415",
- "bundleBenefit": null,
- "mediaAddons": null,
- "premiumAddons": null,
- "basicService": "U+ 모바일tv 기본 월정액 무료, U+멤버십 VIP 등급 혜택"
- },
- {
- "_id": "8",
- "category": "5G",
- "name": "유쓰 5G 스탠다드",
- "description": "일반 5G요금제보다 더 넉넉한 데이터를 이용할 수 있는 청년 전용 5G요금제",
- "isPopular": true,
- "dataGb": 210.0,
- "sharedDataGb": 65,
- "voiceMinutes": -1,
- "addonVoiceMinutes": 300,
- "smsCount": -1,
- "monthlyFee": 75000,
- "optionalDiscountAmount": 56250,
- "ageGroup": "YOUTH",
- "detailUrl": "https://www.lguplus.com/mobile/plan/mplan/5g-all/5g-young/LPZ1000232",
- "bundleBenefit": null,
- "mediaAddons": null,
- "premiumAddons": null,
- "basicService": "U+ 모바일tv 기본 월정액 무료, U+멤버십 VIP 등급 혜택"
- },
- {
- "_id": "9",
- "category": "5G",
- "name": "5G 스탠다드 에센셜",
- "description": "필요한 만큼만 데이터를 선택할 수 있고, 다 쓰고 난 후에도 추가 요금 없이 데이터를 사용할 수 있는 요금제",
- "isPopular": false,
- "dataGb": 125.0,
- "sharedDataGb": 55,
- "voiceMinutes": -1,
- "addonVoiceMinutes": 300,
- "smsCount": -1,
- "monthlyFee": 70000,
- "optionalDiscountAmount": 52500,
- "ageGroup": "ALL",
- "detailUrl": "https://www.lguplus.com/mobile/plan/mplan/5g-all/5g-unlimited/LPZ0000784",
- "bundleBenefit": null,
- "mediaAddons": null,
- "premiumAddons": null,
- "basicService": "U+ 모바일tv 기본 월정액 무료"
- },
- {
- "_id": "10",
- "category": "5G",
- "name": "유쓰 5G 스탠다드 에센셜",
- "description": "일반 5G요금제보다 더 넉넉한 데이터를 이용할 수 있는 청년 전용 5G요금제",
- "isPopular": false,
- "dataGb": 185.0,
- "sharedDataGb": 60,
- "voiceMinutes": -1,
- "addonVoiceMinutes": 300,
- "smsCount": -1,
- "monthlyFee": 70000,
- "optionalDiscountAmount": 52500,
- "ageGroup": "YOUTH",
- "detailUrl": "https://www.lguplus.com/mobile/plan/mplan/5g-all/5g-young/LPZ1000231",
- "bundleBenefit": null,
- "mediaAddons": null,
- "premiumAddons": null,
- "basicService": "U+ 모바일tv 기본 월정액 무료"
- }
-
-
-
-📌 결합 혜택 설명:
+**참고자료 (결합 혜택 설명):**
- 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)
-📌 부가서비스 설명:
+부가서비스 설명:
- 바이브 앱 음악감상: 5G 프리미어 슈퍼, 5G 프리미어 플러스, LTE 프리미어 플러스, 다이렉트, 현역병사 요금제 이용 고객이 '미디어 서비스'로 선택해, 바이브 모바일 앱에서 음악감상을 즐길 수 있는 서비스 (링크:https://www.lguplus.com/mobile/plan/addon/addon-media/Z202306082)
- 유플레이 프리미엄 1년 약정: 유플레이의 일부 콘텐츠를 저렴한 가격에 시청할 수 있는 실속형 상품입니다. (U+tv, 휴대폰 앱 시청 가능) (링크:https://www.lguplus.com/mobile/plan/addon/addon-media/LRZ0003859)
- 지니뮤직 앱 음악감상: 5G 프리미어 슈퍼, 5G/LTE 프리미어 플러스, 5G 다이렉트 플러스 69 요금제 이용 고객이 '미디어 서비스'로 선택해, 지니뮤직 앱에서 음악감상을 무제한 즐길 수 있는 서비스(링크:https://www.lguplus.com/mobile/plan/addon/addon-media/LRZ0002943)
diff --git a/test-search-plans.js b/test-search-plans.js
new file mode 100644
index 0000000..0e2fa0c
--- /dev/null
+++ b/test-search-plans.js
@@ -0,0 +1,70 @@
+import dotenv from 'dotenv';
+import mongoose from 'mongoose';
+import { searchPlansFromDB } from './server/services/gptFuncDefinitions.js';
+
+dotenv.config();
+
+async function testSearchPlans() {
+ try {
+ // MongoDB 연결
+ console.log('🔗 MongoDB 연결 중...');
+ await mongoose.connect(process.env.MONGO_URI, { dbName: 'meplus' });
+ console.log('✅ MongoDB 연결 성공!');
+
+ // 테스트 케이스들
+ const testCases = [
+ {
+ name: '5G 인기 요금제 3개',
+ conditions: {
+ category: '5G',
+ isPopular: true,
+ limit: 3,
+ },
+ },
+ {
+ name: '8만원 이하 요금제',
+ conditions: {
+ maxMonthlyFee: 80000,
+ limit: 3,
+ },
+ },
+ {
+ name: '청년 대상 요금제',
+ conditions: {
+ ageGroup: 'YOUTH',
+ limit: 3,
+ },
+ },
+ {
+ name: '무제한 데이터 요금제',
+ conditions: {
+ minDataGb: -1,
+ limit: 3,
+ },
+ },
+ ];
+
+ // 각 테스트 케이스 실행
+ for (const testCase of testCases) {
+ console.log(`\n🧪 테스트: ${testCase.name}`);
+ console.log('📋 조건:', JSON.stringify(testCase.conditions, null, 2));
+
+ const result = await searchPlansFromDB(testCase.conditions);
+ console.log(`✅ 결과: ${result.plans.length}개 요금제 찾음`);
+
+ result.plans.forEach((plan, index) => {
+ console.log(
+ ` ${index + 1}. ${plan.name} (${plan.monthlyFee.toLocaleString()}원) ${plan.isPopular ? '⭐' : ''}`,
+ );
+ });
+ }
+ } catch (error) {
+ console.error('❌ 테스트 실패:', error);
+ } finally {
+ await mongoose.disconnect();
+ console.log('\n🔌 MongoDB 연결 종료');
+ process.exit(0);
+ }
+}
+
+testSearchPlans();