Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,5 @@
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"vite": "^6.4.3"
},
"pnpm": {
"overrides": {
"form-data": "4.0.6"
}
}
}
3 changes: 3 additions & 0 deletions frontend/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
packages:
- .

overrides:
form-data: 4.0.6

allowBuilds:
esbuild: true
vue-demi: true
13 changes: 12 additions & 1 deletion frontend/src/api/chat-normalizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,24 @@ 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 {
intent: data.intent || '',
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)
}
}
11 changes: 8 additions & 3 deletions frontend/src/api/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
129 changes: 129 additions & 0 deletions frontend/src/api/chat.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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
}
}
])
})
10 changes: 7 additions & 3 deletions frontend/src/store/mapStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export default defineStore('map', {
{
role: 'bot',
text: '계약서, 보증금 회수, 확정일자처럼 헷갈리는 전월세 법률 질문을 물어보세요.',
legalCards: []
legalCards: [],
analysisCards: []
}
],
lastFetchedAt: null,
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -217,7 +220,8 @@ export default defineStore('map', {
role: 'bot',
text: this.chatError,
isError: true,
legalCards: []
legalCards: [],
analysisCards: []
})
} finally {
if (seq === this.chatRequestSeq) {
Expand Down
106 changes: 106 additions & 0 deletions frontend/src/views/Chatbot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,43 @@
<p class="mt-2 text-xs leading-5 text-slate-600">{{ card.content }}</p>
</article>
</div>

<div v-if="message.analysisCards?.length" class="mt-3 space-y-2">
<article
v-for="card in message.analysisCards"
:key="`${card.type}-${card.title}`"
class="rounded-lg border border-slate-200 bg-white p-3 text-slate-800"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-[11px] font-black tracking-normal text-brand-dark">{{ analysisLabel(card.type) }}</p>
<h3 class="mt-1 text-sm font-black text-slate-900">{{ card.title || analysisTitle(card.type) }}</h3>
</div>
<span
v-if="card.score !== null"
:class="[
'shrink-0 rounded-md px-2 py-1 text-[11px] font-black',
card.type === 'SAFETY' ? 'bg-emerald-100 text-emerald-800' : 'bg-slate-100 text-slate-700'
]"
>
{{ card.score }}점
</span>
</div>
<p v-if="card.summary" class="mt-2 text-xs leading-5 text-slate-600">{{ card.summary }}</p>
<template v-for="metricEntries in [analysisMetricEntries(card)]" :key="`${card.type}-${card.title}-metrics`">
<dl v-if="metricEntries.length" class="mt-3 grid grid-cols-2 gap-2">
<div
v-for="metric in metricEntries"
:key="metric.key"
class="rounded-md border border-slate-100 bg-slate-50 px-2 py-2"
>
<dt class="text-[11px] font-bold text-slate-500">{{ metric.label }}</dt>
<dd class="mt-1 text-xs font-black text-slate-900">{{ metric.value }}</dd>
</div>
</dl>
</template>
</article>
</div>
</article>
</div>

Expand Down Expand Up @@ -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) {
Expand Down