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
7 changes: 2 additions & 5 deletions backend-ai/app/clients/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,8 @@ def generate_answer(self, state: AgentState) -> str:
)
else:
live = self._generate_live_property_search_answer(state)
if live:
parts.append(live)
else:
parts.append(f"조건에 맞는 매물 {count}개를 찾았습니다.")
base = live if live else f"조건에 맞는 매물 {count}개를 찾았습니다."
parts.append(base + "\n아래 매물 중 하나를 선택하면 시세·안전 분석을 해 드릴게요.")

if parts:
return "\n\n".join(parts)
Expand Down Expand Up @@ -371,7 +369,6 @@ def _generate_live_property_search_answer(self, state: AgentState) -> str | None
except (httpx.HTTPError, KeyError, TypeError, ValueError):
return None


def _generate_live_analysis_answer(self, state: AgentState) -> str | None:
analysis_cards = state.get("analysis_cards", [])
tool_results = state.get("tool_results", {})
Expand Down
1 change: 1 addition & 0 deletions backend-ai/app/graph/nodes/price_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def price_analysis(state: AgentState) -> AgentState:
if result.get("requiresSelection"):
result = _regional_price_analysis(state["message"])


updated_tool_results = {
**state.get("tool_results", {}),
"priceAnalysis": result,
Expand Down
17 changes: 13 additions & 4 deletions backend-ai/app/graph/nodes/safety_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ def safety_analysis(state: AgentState) -> AgentState:
message=state["message"],
context=state.get("context", {}),
)

updated_tool_results = {
**state.get("tool_results", {}),
"safetyAnalysis": result,
}

if result.get("requiresSelection"):
return {
**state,
"tool_results": updated_tool_results,
}

metrics = {
"selectedPropertyId": result.get("selectedPropertyId"),
"stub": result.get("stub", False),
Expand All @@ -26,8 +38,5 @@ def safety_analysis(state: AgentState) -> AgentState:
return {
**state,
"analysis_cards": [*state.get("analysis_cards", []), analysis_card],
"tool_results": {
**state.get("tool_results", {}),
"safetyAnalysis": result,
},
"tool_results": updated_tool_results,
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.ssafy.salmanhae.model.dto.chat;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

Expand All @@ -13,7 +14,7 @@ public record ChatResponse(
) {
public ChatResponse {
properties = properties == null ? List.of() : properties.stream()
.map(Map::copyOf)
.<Map<String, Object>>map(HashMap::new)
.toList();
legalCards = legalCards == null ? List.of() : List.copyOf(legalCards);
analysisCards = analysisCards == null ? List.of() : List.copyOf(analysisCards);
Expand Down
3 changes: 3 additions & 0 deletions backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ spring.datasource.url=${SUPABASE_DB_URL}
spring.datasource.username=${SUPABASE_DB_USERNAME}
spring.datasource.password=${SUPABASE_DB_PASSWORD}

# HikariCP - disable server-side prepared statements to prevent "prepared statement already exists" errors on connection reuse
spring.datasource.hikari.data-source-properties.prepareThreshold=0

# MyBatis
mybatis.mapper-locations=classpath:mappers/**/*.xml
mybatis.type-aliases-package=com.ssafy.salmanhae.model.dto
Expand Down
13 changes: 9 additions & 4 deletions frontend/src/store/chatSessionStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ export const useChatSessionStore = defineStore('chatSession', {
chatMessages: (state) =>
state.sessions.find((s) => s.id === state.currentSessionId)?.messages ?? [],
sessionList: (state) =>
[...state.sessions].sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
[...state.sessions]
.filter((s) => s.messages.length > 0)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
},

actions: {
Expand All @@ -54,11 +56,14 @@ export const useChatSessionStore = defineStore('chatSession', {
}
},

createSession() {
newChat() {
this.currentSessionId = null
},

_createSession() {
const session = makeSession()
this.sessions.unshift(session)
this.currentSessionId = session.id
this._persist()
return session
},

Expand All @@ -70,7 +75,7 @@ export const useChatSessionStore = defineStore('chatSession', {

_ensureSession() {
if (!this.currentSessionId || !this.sessions.some((s) => s.id === this.currentSessionId)) {
this.createSession()
this._createSession()
}
},

Expand Down
159 changes: 158 additions & 1 deletion frontend/src/views/Chatbot.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
<template>
<div class="chat-page">
<div class="chat-outer">

<!-- Session Sidebar -->
<div class="chat-sidebar">
<button class="new-chat-btn" @click="chatStore.newChat()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
새 대화
</button>

<div class="session-list">
<button
v-for="session in chatStore.sessionList"
:key="session.id"
:class="['session-item', session.id === chatStore.currentSessionId && 'session-item--active']"
@click="chatStore.switchSession(session.id)"
>
<p class="session-item__title">{{ session.title }}</p>
<p class="session-item__date">{{ formatSessionDate(session.createdAt) }}</p>
</button>
</div>
</div>

<div class="chat-card">

<!-- Body -->
Expand Down Expand Up @@ -34,6 +55,28 @@
<div :class="['msg-bubble', msg.role === 'user' ? 'msg-bubble--user' : 'msg-bubble--bot', msg.isError && 'msg-bubble--error']">
<p class="msg-text">{{ msg.text }}</p>

<div v-if="msg.properties?.length" class="card-list">
<div
v-for="prop in msg.properties"
:key="prop.id"
class="property-card"
@click="store.selectProperty && store.selectProperty(prop)"
>
<div class="property-card__head">
<div class="property-card__info">
<p class="info-card__tag">{{ propTypeLabel(prop.property_type) }} · {{ txTypeLabel(prop.transaction_type) }}</p>
<h4 class="info-card__title">{{ prop.building_name || prop.title }}</h4>
<p class="property-card__address">{{ prop.address }}</p>
</div>
<p class="property-card__price">{{ formatPropertyPrice(prop) }}</p>
</div>
<div v-if="prop.area_m2 || prop.floor" class="property-card__meta">
<span v-if="prop.area_m2">{{ prop.area_m2 }}㎡</span>
<span v-if="prop.floor">{{ prop.floor }}층</span>
</div>
</div>
</div>

<div v-if="msg.legalCards?.length" class="card-list">
<div v-for="card in msg.legalCards" :key="`${card.lawName}-${card.articleNo}`" class="info-card">
<div class="info-card__head">
Expand Down Expand Up @@ -160,6 +203,21 @@ const examplePrompts = [
{ text: '전세사기 피해지원 특별법은 어떤 경우에 도움이 되나요?', icon: iconChat2 },
]

const propTypeLabels = {
ONE_ROOM: '원룸', OFFICETEL: '오피스텔', VILLA: '빌라',
APARTMENT: '아파트', MULTI_FAMILY: '다가구',
}
const txTypeLabels = { MONTHLY_RENT: '월세', JEONSE: '전세', SALE: '매매' }
const propTypeLabel = (t) => propTypeLabels[t] || t || ''
const txTypeLabel = (t) => txTypeLabels[t] || t || ''
const formatPropertyPrice = (prop) => {
const tx = prop.transaction_type
if (tx === 'MONTHLY_RENT') return `${Number(prop.deposit || 0).toLocaleString()}/${Number(prop.monthly_rent || 0).toLocaleString()}만`
if (tx === 'JEONSE') return `전세 ${Number(prop.deposit || 0).toLocaleString()}만`
if (tx === 'SALE') return `매매 ${Number(prop.price || 0).toLocaleString()}만`
return ''
}

const analysisLabels = { PRICE: 'PRICE ANALYSIS', SAFETY: 'SAFETY ANALYSIS' }
const analysisTitles = { PRICE: '시세 분석', SAFETY: '안전 분석' }
const metricLabels = {
Expand Down Expand Up @@ -190,6 +248,15 @@ const analysisMetricEntries = (card) => {
.map((k) => ({ key: k, label: metricLabels[k] || k, value: formatMetricValue(k, m[k]) }))
}

const formatSessionDate = (iso) => {
const d = new Date(iso)
const diffDays = Math.floor((Date.now() - d) / 86400000)
if (diffDays === 0) return '오늘'
if (diffDays === 1) return '어제'
if (diffDays < 7) return `${diffDays}일 전`
return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
}

const autoResize = () => {
if (!textarea.value) return
textarea.value.style.height = 'auto'
Expand Down Expand Up @@ -225,17 +292,94 @@ const send = async (text) => {

.chat-outer {
width: 100%;
max-width: 900px;
max-width: 1100px;
flex: 1;
min-height: 0;
display: flex;
padding: 7px;
gap: 7px;
border-radius: 24px;
background: linear-gradient(to bottom, #F1F7F6 0%, #E3F2F0 100%);
border: 1px solid #e9e9eb;
box-shadow: 0 40px 80px 20px rgba(233, 240, 238, 0.25);
}

/* Sidebar */
.chat-sidebar {
width: 200px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 6px;
overflow: hidden;
}

.new-chat-btn {
display: flex;
align-items: center;
gap: 7px;
width: 100%;
padding: 10px 13px;
background: #ffffff;
border: 1px solid #e9e9eb;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
color: #111827;
cursor: pointer;
transition: border-color 0.13s, color 0.13s;
flex-shrink: 0;
}
.new-chat-btn svg { width: 14px; height: 14px; flex-shrink: 0; }
.new-chat-btn:hover { border-color: #01bfa6; color: #01bfa6; }

.session-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.session-list::-webkit-scrollbar { width: 3px; }
.session-list::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }

.session-item {
width: 100%;
padding: 9px 11px;
background: transparent;
border: 1px solid transparent;
border-radius: 10px;
text-align: left;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
}
.session-item:hover { background: #D8E9E7; }
.session-item--active {
background: #D8E9E7;
border-color: transparent;
}
.session-item--active .session-item__title {
color: #1a3d3a;
}
.session-item--active .session-item__date {
color: #5a8480;
}
.session-item__title {
font-size: 12.5px;
font-weight: 500;
color: #111827;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
}
.session-item__date {
font-size: 11px;
color: #9ca3af;
margin: 3px 0 0;
}

.chat-card {
flex: 1;
min-height: 0;
Expand Down Expand Up @@ -349,6 +493,19 @@ const send = async (text) => {
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce { 0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-6px)} }

/* Property cards */
.property-card {
background: #fff; border: 1px solid #e5e7eb; border-radius: 10px;
padding: 12px; cursor: pointer; transition: border-color 0.13s, box-shadow 0.13s;
}
.property-card:hover { border-color: #01bfa6; box-shadow: 0 2px 8px rgba(1,191,166,0.1); }
.property-card__head { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
.property-card__info { flex: 1; min-width: 0; }
.property-card__address { font-size: 11px; color: #9ca3af; margin: 2px 0 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.property-card__price { font-size: 13px; font-weight: 700; color: #01bfa6; white-space: nowrap; flex-shrink: 0; }
.property-card__meta { display: flex; gap: 8px; margin-top: 6px; }
.property-card__meta span { font-size: 11px; color: #6b7280; background: #f3f4f6; padding: 2px 7px; border-radius: 4px; }

/* Info cards */
.card-list { margin-top: 10px; display: flex; flex-direction: column; gap: 8px; }
.info-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; }
Expand Down
6 changes: 4 additions & 2 deletions scripts/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,15 @@ def run_phase(phase_path: Path, task_name: str) -> tuple[str, str, int]:
"""Claude headless 모드로 Phase를 실행하고 (status, detail, elapsed)을 반환."""
phase_content = phase_path.read_text(encoding="utf-8")
phase_num = re.search(r"phase(\d+)", phase_path.name).group(1)
branch_name = f"phase/{phase_num}-{re.sub(r'^phase\d+-', '', phase_path.stem)}"
phase_slug = re.sub(r"^phase\d+-", "", phase_path.stem)

# GitHub 이슈 생성
issue_number = create_issue(phase_num, phase_path.stem, task_name)
issue_tag = f" (#{issue_number})" if issue_number else ""

# Phase 브랜치 생성
# Phase 브랜치 생성 (이슈 번호 기반)
branch_suffix = str(issue_number) if issue_number else f"p{phase_num}"
branch_name = f"phase/{branch_suffix}-{phase_slug}"
subprocess.run(["git", "checkout", "-b", branch_name], capture_output=True, check=False)

prompt = f"""당신은 살만해 프로젝트의 AI 에이전트입니다.
Expand Down