AI 실거래가 추천 10개 프로토타입
parent
cb5dd5f25f
commit
b1f8061811
|
|
@ -8,11 +8,13 @@ FastAPI와 OpenAI를 활용한 부동산 실거래가 검색 웹 애플리케이
|
|||
- OpenAI API를 통한 자동 정보 추출
|
||||
- 국토교통부 공공데이터 API 연동
|
||||
- 실제 실거래가 데이터 조회 및 표시
|
||||
- **AI 기반 매물 추천**: 사용자 요구사항과 가장 일치하는 TOP 10 매물 선별
|
||||
- 지원 정보:
|
||||
- 매물 형태 (아파트, 오피스텔, 빌라, 주택 등)
|
||||
- 거래 유형 (전세, 월세, 매매)
|
||||
- 지역 자동 인식 및 코드 변환
|
||||
- 실거래가 목록 표시
|
||||
- AI 필터링을 통한 최적 매물 추천
|
||||
|
||||
## 설치
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ async def serve_index():
|
|||
return FileResponse("../frontend/index.html")
|
||||
|
||||
@app.post("/api/search")
|
||||
async def search_real_estate(query: RealEstateQuery):
|
||||
async def search_real_estate(query: RealEstateQuery, filter_results: bool = True):
|
||||
"""자연어 검색 후 실거래가 데이터 조회"""
|
||||
if not parser:
|
||||
raise HTTPException(
|
||||
|
|
@ -60,10 +60,55 @@ async def search_real_estate(query: RealEstateQuery):
|
|||
region_code=parsed.region_code
|
||||
)
|
||||
|
||||
# 3. OpenAI로 필터링 (옵션)
|
||||
if filter_results and listings:
|
||||
filtered_listings = await parser.filter_listings(
|
||||
user_query=query.text,
|
||||
listings=listings,
|
||||
top_k=10
|
||||
)
|
||||
return {
|
||||
"parsed": parsed,
|
||||
"listings": listings,
|
||||
"count": len(listings)
|
||||
"listings": filtered_listings,
|
||||
"count": len(filtered_listings),
|
||||
"total_count": len(listings),
|
||||
"filtered": True
|
||||
}
|
||||
|
||||
return {
|
||||
"parsed": parsed,
|
||||
"listings": listings[:20], # 필터링 안 할 경우 상위 20개만
|
||||
"count": min(len(listings), 20),
|
||||
"total_count": len(listings),
|
||||
"filtered": False
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/api/filter")
|
||||
async def filter_real_estate_listings(
|
||||
user_query: str,
|
||||
listings: list,
|
||||
top_k: int = 10
|
||||
):
|
||||
"""공공데이터 결과를 OpenAI로 필터링하여 최적 매물 선별"""
|
||||
if not parser:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="OpenAI API key not configured"
|
||||
)
|
||||
|
||||
try:
|
||||
filtered = await parser.filter_listings(
|
||||
user_query=user_query,
|
||||
listings=listings,
|
||||
top_k=top_k
|
||||
)
|
||||
|
||||
return {
|
||||
"filtered_listings": filtered,
|
||||
"count": len(filtered),
|
||||
"original_count": len(listings)
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
|
|||
|
|
@ -137,3 +137,96 @@ class OpenAIParser:
|
|||
region_code=None,
|
||||
region_name=None
|
||||
)
|
||||
|
||||
async def filter_listings(self, user_query: str, listings: list, top_k: int = 10) -> list:
|
||||
"""
|
||||
OpenAI를 사용하여 실거래가 목록에서 사용자 요구사항과 가장 일치하는 매물 선별
|
||||
|
||||
Args:
|
||||
user_query: 사용자의 원본 검색 쿼리
|
||||
listings: 공공데이터 API에서 받은 실거래가 목록
|
||||
top_k: 선별할 매물 개수 (기본값: 10)
|
||||
|
||||
Returns:
|
||||
필터링된 매물 목록
|
||||
"""
|
||||
|
||||
if not listings:
|
||||
return []
|
||||
|
||||
# 매물 정보를 간략하게 정리
|
||||
simplified_listings = []
|
||||
for idx, item in enumerate(listings[:50]): # 최대 50개만 처리 (토큰 제한)
|
||||
simplified = {
|
||||
"index": idx,
|
||||
"거래유형": item.get("거래유형", ""),
|
||||
"매물형태": item.get("매물형태", ""),
|
||||
"거래금액": item.get("거래금액", ""),
|
||||
"보증금액": item.get("보증금액", ""),
|
||||
"월세금액": item.get("월세금액", ""),
|
||||
"전용면적": item.get("전용면적", ""),
|
||||
"층": item.get("층", ""),
|
||||
"건축년도": item.get("건축년도", ""),
|
||||
"법정동": item.get("법정동", ""),
|
||||
"거래일": f"{item.get('년', '')}.{item.get('월', '')}.{item.get('일', '')}"
|
||||
}
|
||||
# 빈 값 제거
|
||||
simplified = {k: v for k, v in simplified.items() if v}
|
||||
simplified_listings.append(simplified)
|
||||
|
||||
system_prompt = """
|
||||
당신은 부동산 전문가입니다.
|
||||
사용자의 요구사항과 실거래가 데이터를 비교하여 가장 적합한 매물을 선별해주세요.
|
||||
|
||||
평가 기준:
|
||||
1. 가격 조건 일치도 (사용자가 언급한 가격 범위)
|
||||
2. 면적 조건 일치도 (평수, 방 개수 등)
|
||||
3. 층수 선호도
|
||||
4. 건축년도 (신축/구축 선호도)
|
||||
5. 위치 적합성
|
||||
|
||||
JSON 형식으로 응답하세요:
|
||||
{
|
||||
"selected_indices": [가장 적합한 매물의 index 10개],
|
||||
"reason": "선별 이유 간단 설명"
|
||||
}
|
||||
"""
|
||||
|
||||
user_prompt = f"""
|
||||
사용자 요구사항: {user_query}
|
||||
|
||||
실거래가 데이터:
|
||||
{json.dumps(simplified_listings, ensure_ascii=False, indent=2)}
|
||||
|
||||
위 데이터에서 사용자 요구사항과 가장 잘 맞는 매물 {top_k}개를 선별해주세요.
|
||||
"""
|
||||
|
||||
try:
|
||||
response = self.client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
],
|
||||
temperature=0.1,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
selected_indices = result.get("selected_indices", [])
|
||||
|
||||
# 선별된 매물 반환
|
||||
filtered_listings = []
|
||||
for idx in selected_indices:
|
||||
if 0 <= idx < len(listings):
|
||||
listing = listings[idx].copy()
|
||||
listing["match_reason"] = result.get("reason", "")
|
||||
listing["rank"] = len(filtered_listings) + 1
|
||||
filtered_listings.append(listing)
|
||||
|
||||
return filtered_listings[:top_k]
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error filtering listings: {e}")
|
||||
# 에러 시 원본 데이터의 상위 10개 반환
|
||||
return listings[:top_k]
|
||||
|
|
@ -47,6 +47,37 @@
|
|||
- docs/project_plan.md 업데이트 (API 가이드 문서 추가)
|
||||
- 웹 검색 및 브라우저를 통한 실제 API 정보 수집
|
||||
|
||||
[2025-08-19 15:22:10] OpenAI 기반 매물 필터링 기능 추가 완료
|
||||
- backend/openai_parser.py 수정
|
||||
- filter_listings 메서드 추가
|
||||
- 공공데이터 결과를 OpenAI로 분석
|
||||
- 사용자 요구사항과 일치도 평가
|
||||
- 가격, 면적, 층수, 건축년도 등 종합 평가
|
||||
- 상위 10개 매물 선별
|
||||
- backend/main.py 수정
|
||||
- /api/search에 filter_results 파라미터 추가
|
||||
- 필터링 옵션 적용 (기본값: True)
|
||||
- /api/filter 엔드포인트 추가 (별도 필터링 API)
|
||||
- frontend/script.js 수정
|
||||
- 필터링 결과 표시 UI 개선
|
||||
- AI 추천 매물 TOP 10 표시
|
||||
- 순위 배지 추가
|
||||
- frontend/style.css 수정
|
||||
- 필터링 정보 스타일 추가
|
||||
- 순위 배지 디자인
|
||||
- 추천 매물 강조 효과
|
||||
- README.md 및 문서 업데이트
|
||||
- 필터링 평가 기준:
|
||||
- 가격 조건 일치도
|
||||
- 면적 조건 일치도
|
||||
- 층수 선호도
|
||||
- 건축년도 (신축/구축)
|
||||
- 위치 적합성
|
||||
|
||||
[2025-08-19 15:17:59] OpenAI 기반 매물 필터링 기능 추가 시작
|
||||
- 공공데이터 결과를 OpenAI로 분석
|
||||
- 사용자 요구사항과 가장 일치하는 10개 선별
|
||||
|
||||
[2025-08-19 15:12:44] 사용하지 않는 API 엔드포인트 제거 작업 완료
|
||||
- backend/main.py 정리
|
||||
- /api/parse 엔드포인트 제거 (사용 안함)
|
||||
|
|
|
|||
|
|
@ -52,8 +52,14 @@ C:\o2o\RealEstateSearch\
|
|||
|
||||
### 2. POST `/api/search`
|
||||
- 자연어 검색 및 실거래가 조회
|
||||
- 요청: `{"text": "강남 아파트 전세"}`
|
||||
- 응답: 파싱 결과 + 실거래가 목록
|
||||
- AI 필터링 옵션 (filter_results 파라미터)
|
||||
- 요청: `{"text": "강남 아파트 전세 3억"}`
|
||||
- 응답: 파싱 결과 + 실거래가 목록 (필터링 포함)
|
||||
|
||||
### 3. GET `/static/*`
|
||||
### 3. POST `/api/filter`
|
||||
- 공공데이터 결과를 OpenAI로 필터링
|
||||
- 사용자 요구사항과 가장 일치하는 매물 선별
|
||||
- 파라미터: user_query, listings, top_k
|
||||
|
||||
### 4. GET `/static/*`
|
||||
- CSS, JS 등 정적 파일 제공
|
||||
|
|
|
|||
|
|
@ -75,18 +75,25 @@ function displayResults(data) {
|
|||
// 실거래가 목록
|
||||
if (data.listings && data.listings.length > 0) {
|
||||
html += '<div class="listings-section">';
|
||||
html += `<h3>📊 실거래가 정보 (${data.count}건)</h3>`;
|
||||
html += '<div class="listings-container">';
|
||||
|
||||
// 최대 20개만 표시
|
||||
const displayCount = Math.min(data.listings.length, 20);
|
||||
for (let i = 0; i < displayCount; i++) {
|
||||
const item = data.listings[i];
|
||||
html += createListingCard(item);
|
||||
// 필터링 정보 표시
|
||||
if (data.filtered) {
|
||||
html += `<h3>🎯 AI 추천 매물 TOP ${data.count} (전체 ${data.total_count}건 중)</h3>`;
|
||||
html += '<p class="filter-info">AI가 요구사항과 가장 일치하는 매물을 선별했습니다.</p>';
|
||||
} else {
|
||||
html += `<h3>📊 실거래가 정보 (${data.total_count || data.count}건)</h3>`;
|
||||
}
|
||||
|
||||
if (data.listings.length > 20) {
|
||||
html += `<p class="more-info">... 외 ${data.listings.length - 20}건</p>`;
|
||||
html += '<div class="listings-container">';
|
||||
|
||||
// 매물 카드 표시
|
||||
for (let i = 0; i < data.listings.length; i++) {
|
||||
const item = data.listings[i];
|
||||
html += createListingCard(item, data.filtered);
|
||||
}
|
||||
|
||||
if (!data.filtered && data.total_count > data.count) {
|
||||
html += `<p class="more-info">... 외 ${data.total_count - data.count}건</p>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
|
@ -108,9 +115,14 @@ function displayResults(data) {
|
|||
}
|
||||
|
||||
// 실거래가 카드 생성
|
||||
function createListingCard(item) {
|
||||
function createListingCard(item, isFiltered = false) {
|
||||
let html = '<div class="listing-card">';
|
||||
|
||||
// 순위 표시 (필터링된 경우)
|
||||
if (isFiltered && item.rank) {
|
||||
html += `<div class="rank-badge">TOP ${item.rank}</div>`;
|
||||
}
|
||||
|
||||
// 제목 (아파트명 또는 주소)
|
||||
const title = item['아파트'] || item['법정동'] || '정보 없음';
|
||||
html += `<h4>${title}</h4>`;
|
||||
|
|
|
|||
|
|
@ -273,3 +273,38 @@ header p {
|
|||
margin-top: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 필터링 정보 */
|
||||
.filter-info {
|
||||
color: #667eea;
|
||||
font-size: 0.95em;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
background: #f0f3ff;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 순위 배지 */
|
||||
.rank-badge {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: 15px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 5px 12px;
|
||||
border-radius: 15px;
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.listing-card {
|
||||
position: relative;
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
|
@ -4,3 +4,4 @@ openai
|
|||
python-dotenv
|
||||
requests
|
||||
pydantic
|
||||
python-multipart
|
||||
Loading…
Reference in New Issue