diff --git a/frontend/package.json b/frontend/package.json index c63bf16..fb8a706 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,10 +22,5 @@ "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "vite": "^6.4.3" - }, - "pnpm": { - "overrides": { - "form-data": "4.0.6" - } } } diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index d9534f8..2f13654 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -1,6 +1,9 @@ packages: - . +overrides: + form-data: 4.0.6 + allowBuilds: esbuild: true vue-demi: true diff --git a/frontend/src/api/chat-normalizer.js b/frontend/src/api/chat-normalizer.js index 248c6b0..35414b6 100644 --- a/frontend/src/api/chat-normalizer.js +++ b/frontend/src/api/chat-normalizer.js @@ -8,6 +8,16 @@ const normalizeLegalCard = (card = {}) => ({ score: typeof card.score === 'number' ? card.score : null }) +const asObject = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : {}) + +const normalizeAnalysisCard = (card = {}) => ({ + type: card.type || '', + title: card.title || '', + summary: card.summary || '', + score: typeof card.score === 'number' ? card.score : null, + metrics: { ...asObject(card.metrics) } +}) + export const normalizeChatResponse = (body = {}) => { const data = body.data || {} return { @@ -15,6 +25,7 @@ export const normalizeChatResponse = (body = {}) => { message: data.message || '', sessionId: data.sessionId || null, properties: asArray(data.properties), - legalCards: asArray(data.legalCards).map(normalizeLegalCard) + legalCards: asArray(data.legalCards).map(normalizeLegalCard), + analysisCards: asArray(data.analysisCards).map(normalizeAnalysisCard) } } diff --git a/frontend/src/api/chat.js b/frontend/src/api/chat.js index f2bdd61..6a26b1f 100644 --- a/frontend/src/api/chat.js +++ b/frontend/src/api/chat.js @@ -3,15 +3,20 @@ import { normalizeChatResponse } from './chat-normalizer.js' export { normalizeChatResponse } -export const sendChatMessage = async ({ message, sessionId = null } = {}, client = http) => { +export const sendChatMessage = async ({ message, sessionId = null, selectedPropertyId = null } = {}, client = http) => { const trimmedMessage = String(message || '').trim() if (!trimmedMessage) { throw new Error('message must not be blank') } - const response = await client.post('/api/v1/chat', { + const payload = { message: trimmedMessage, sessionId - }) + } + if (selectedPropertyId !== null && selectedPropertyId !== undefined && selectedPropertyId !== '') { + payload.selectedPropertyId = selectedPropertyId + } + + const response = await client.post('/api/v1/chat', payload) return normalizeChatResponse(response.data) } diff --git a/frontend/src/api/chat.test.mjs b/frontend/src/api/chat.test.mjs index 484cb8e..7f4221d 100644 --- a/frontend/src/api/chat.test.mjs +++ b/frontend/src/api/chat.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { normalizeChatResponse } from './chat-normalizer.js' +import { sendChatMessage } from './chat.js' test('normalizeChatResponse maps legal RAG response into chat payload', () => { const result = normalizeChatResponse({ @@ -49,4 +50,132 @@ test('normalizeChatResponse tolerates missing optional arrays', () => { assert.deepEqual(result.properties, []) assert.deepEqual(result.legalCards, []) + assert.deepEqual(result.analysisCards, []) +}) + +test('sendChatMessage includes selected property id in request payload', async () => { + let capturedUrl = '' + let capturedPayload = null + const client = { + async post(url, payload) { + capturedUrl = url + capturedPayload = payload + return { + data: { + data: { + intent: 'PRICE_ANALYSIS', + message: '분석했습니다.', + sessionId: 'session-1', + properties: [], + legalCards: [], + analysisCards: [] + } + } + } + } + } + + await sendChatMessage( + { + message: '이 매물 시세 어때?', + sessionId: 'session-1', + selectedPropertyId: 17 + }, + client + ) + + assert.equal(capturedUrl, '/api/v1/chat') + assert.deepEqual(capturedPayload, { + message: '이 매물 시세 어때?', + sessionId: 'session-1', + selectedPropertyId: 17 + }) +}) + +test('sendChatMessage omits empty selected property id', async () => { + let capturedPayload = null + const client = { + async post(_url, payload) { + capturedPayload = payload + return { + data: { + data: { + message: 'OK' + } + } + } + } + } + + await sendChatMessage( + { + message: '안전 분석해줘', + selectedPropertyId: null + }, + client + ) + + assert.deepEqual(capturedPayload, { + message: '안전 분석해줘', + sessionId: null + }) +}) + +test('normalizeChatResponse maps price and safety analysis cards', () => { + const result = normalizeChatResponse({ + data: { + intent: 'SAFETY_ANALYSIS', + message: '주변 안전 데이터는 안전 점수 78점으로 확인됩니다.', + sessionId: 'session-2', + analysisCards: [ + { + type: 'PRICE', + title: '시세 분석', + summary: '최근 실거래 2건을 기준으로 확인했습니다.', + score: null, + metrics: { + comparableTransactionCount: 2, + avgDeposit: 10500000, + avgMonthlyRent: 520000 + } + }, + { + type: 'SAFETY', + title: '안전 분석', + summary: '반경 500m 기준 안전 점수는 78점입니다.', + score: 78, + metrics: { + radius: 500, + cctvCount300m: 8, + bellCount300m: 0 + } + } + ] + } + }) + + assert.deepEqual(result.analysisCards, [ + { + type: 'PRICE', + title: '시세 분석', + summary: '최근 실거래 2건을 기준으로 확인했습니다.', + score: null, + metrics: { + comparableTransactionCount: 2, + avgDeposit: 10500000, + avgMonthlyRent: 520000 + } + }, + { + type: 'SAFETY', + title: '안전 분석', + summary: '반경 500m 기준 안전 점수는 78점입니다.', + score: 78, + metrics: { + radius: 500, + cctvCount300m: 8, + bellCount300m: 0 + } + } + ]) }) diff --git a/frontend/src/store/mapStore.js b/frontend/src/store/mapStore.js index 1d4d57d..eac20d4 100644 --- a/frontend/src/store/mapStore.js +++ b/frontend/src/store/mapStore.js @@ -50,7 +50,8 @@ export default defineStore('map', { { role: 'bot', text: '계약서, 보증금 회수, 확정일자처럼 헷갈리는 전월세 법률 질문을 물어보세요.', - legalCards: [] + legalCards: [], + analysisCards: [] } ], lastFetchedAt: null, @@ -197,7 +198,8 @@ export default defineStore('map', { try { const response = await sendChatMessage({ message: text, - sessionId: this.chatSessionId + sessionId: this.chatSessionId, + selectedPropertyId: this.selectedPropertyId }) if (seq !== this.chatRequestSeq) return @@ -208,6 +210,7 @@ export default defineStore('map', { text: response.message, intent: response.intent, legalCards: response.legalCards, + analysisCards: response.analysisCards, properties: response.properties }) } catch (error) { @@ -217,7 +220,8 @@ export default defineStore('map', { role: 'bot', text: this.chatError, isError: true, - legalCards: [] + legalCards: [], + analysisCards: [] }) } finally { if (seq === this.chatRequestSeq) { diff --git a/frontend/src/views/Chatbot.vue b/frontend/src/views/Chatbot.vue index a273fa5..d8b7aa2 100644 --- a/frontend/src/views/Chatbot.vue +++ b/frontend/src/views/Chatbot.vue @@ -51,6 +51,43 @@

{{ card.content }}

+ +
+
+
+
+

{{ analysisLabel(card.type) }}

+

{{ card.title || analysisTitle(card.type) }}

+
+ + {{ card.score }}점 + +
+

{{ card.summary }}

+ +
+
@@ -109,6 +146,75 @@ const quickQuestions = [ '전세사기 피해지원 특별법은 어떤 경우에 도움이 되나요?' ] +const analysisLabels = { + PRICE: 'PRICE ANALYSIS', + SAFETY: 'SAFETY ANALYSIS' +} + +const analysisTitles = { + PRICE: '시세 분석', + SAFETY: '안전 분석' +} + +const metricLabels = { + selectedPropertyId: '선택 매물', + comparableTransactionCount: '실거래', + regionStatCount: '지역 통계', + buildingStatCount: '건물 통계', + avgDeposit: '평균 보증금', + avgMonthlyRent: '평균 월세', + avgSalePrice: '평균 매매가', + radius: '반경', + safetyScore: '안전 점수', + cctvCount300m: 'CCTV', + bellCount300m: '비상벨', + lightCount300m: '보안등', + policeCount500m: '파출소' +} + +const metricOrder = [ + 'comparableTransactionCount', + 'avgDeposit', + 'avgMonthlyRent', + 'avgSalePrice', + 'radius', + 'safetyScore', + 'cctvCount300m', + 'bellCount300m', + 'lightCount300m', + 'policeCount500m' +] + +const analysisLabel = (type) => analysisLabels[type] || 'ANALYSIS' +const analysisTitle = (type) => analysisTitles[type] || '분석 결과' + +const formatWon = (value) => { + const numberValue = Number(value) + if (!Number.isFinite(numberValue)) return String(value) + return `${numberValue.toLocaleString()}원` +} + +const formatMetricValue = (key, value) => { + if (['avgDeposit', 'avgMonthlyRent', 'avgSalePrice'].includes(key)) return formatWon(value) + if (key === 'radius') return `${value}m` + if (key === 'safetyScore') return `${value}점` + if (key === 'comparableTransactionCount' || key === 'regionStatCount' || key === 'buildingStatCount') return `${value}건` + if (['cctvCount300m', 'bellCount300m', 'lightCount300m', 'policeCount500m'].includes(key)) return `${value}개` + return String(value) +} + +const analysisMetricEntries = (card) => { + const metrics = card?.metrics && typeof card.metrics === 'object' ? card.metrics : {} + return metricOrder + .filter((key) => metrics[key] !== null && metrics[key] !== undefined && metrics[key] !== '') + .slice(0, 4) + .map((key) => ({ + key, + label: metricLabels[key] || key, + value: formatMetricValue(key, metrics[key]) + })) +} + const scrollToBottom = async () => { await nextTick() if (messageArea.value) {