AI 실거래가 추천 10개 프로토타입
parent
cb5dd5f25f
commit
b1f8061811
|
|
@ -8,11 +8,13 @@ FastAPI와 OpenAI를 활용한 부동산 실거래가 검색 웹 애플리케이
|
||||||
- OpenAI API를 통한 자동 정보 추출
|
- OpenAI API를 통한 자동 정보 추출
|
||||||
- 국토교통부 공공데이터 API 연동
|
- 국토교통부 공공데이터 API 연동
|
||||||
- 실제 실거래가 데이터 조회 및 표시
|
- 실제 실거래가 데이터 조회 및 표시
|
||||||
|
- **AI 기반 매물 추천**: 사용자 요구사항과 가장 일치하는 TOP 10 매물 선별
|
||||||
- 지원 정보:
|
- 지원 정보:
|
||||||
- 매물 형태 (아파트, 오피스텔, 빌라, 주택 등)
|
- 매물 형태 (아파트, 오피스텔, 빌라, 주택 등)
|
||||||
- 거래 유형 (전세, 월세, 매매)
|
- 거래 유형 (전세, 월세, 매매)
|
||||||
- 지역 자동 인식 및 코드 변환
|
- 지역 자동 인식 및 코드 변환
|
||||||
- 실거래가 목록 표시
|
- 실거래가 목록 표시
|
||||||
|
- AI 필터링을 통한 최적 매물 추천
|
||||||
|
|
||||||
## 설치
|
## 설치
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ async def serve_index():
|
||||||
return FileResponse("../frontend/index.html")
|
return FileResponse("../frontend/index.html")
|
||||||
|
|
||||||
@app.post("/api/search")
|
@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:
|
if not parser:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -60,10 +60,55 @@ async def search_real_estate(query: RealEstateQuery):
|
||||||
region_code=parsed.region_code
|
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 {
|
return {
|
||||||
"parsed": parsed,
|
"parsed": parsed,
|
||||||
"listings": listings,
|
"listings": filtered_listings,
|
||||||
"count": len(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:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
|
||||||
|
|
@ -137,3 +137,96 @@ class OpenAIParser:
|
||||||
region_code=None,
|
region_code=None,
|
||||||
region_name=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 가이드 문서 추가)
|
- docs/project_plan.md 업데이트 (API 가이드 문서 추가)
|
||||||
- 웹 검색 및 브라우저를 통한 실제 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 엔드포인트 제거 작업 완료
|
[2025-08-19 15:12:44] 사용하지 않는 API 엔드포인트 제거 작업 완료
|
||||||
- backend/main.py 정리
|
- backend/main.py 정리
|
||||||
- /api/parse 엔드포인트 제거 (사용 안함)
|
- /api/parse 엔드포인트 제거 (사용 안함)
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,14 @@ C:\o2o\RealEstateSearch\
|
||||||
|
|
||||||
### 2. POST `/api/search`
|
### 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 등 정적 파일 제공
|
- CSS, JS 등 정적 파일 제공
|
||||||
|
|
|
||||||
|
|
@ -75,18 +75,25 @@ function displayResults(data) {
|
||||||
// 실거래가 목록
|
// 실거래가 목록
|
||||||
if (data.listings && data.listings.length > 0) {
|
if (data.listings && data.listings.length > 0) {
|
||||||
html += '<div class="listings-section">';
|
html += '<div class="listings-section">';
|
||||||
html += `<h3>📊 실거래가 정보 (${data.count}건)</h3>`;
|
|
||||||
html += '<div class="listings-container">';
|
|
||||||
|
|
||||||
// 최대 20개만 표시
|
// 필터링 정보 표시
|
||||||
const displayCount = Math.min(data.listings.length, 20);
|
if (data.filtered) {
|
||||||
for (let i = 0; i < displayCount; i++) {
|
html += `<h3>🎯 AI 추천 매물 TOP ${data.count} (전체 ${data.total_count}건 중)</h3>`;
|
||||||
const item = data.listings[i];
|
html += '<p class="filter-info">AI가 요구사항과 가장 일치하는 매물을 선별했습니다.</p>';
|
||||||
html += createListingCard(item);
|
} else {
|
||||||
|
html += `<h3>📊 실거래가 정보 (${data.total_count || data.count}건)</h3>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.listings.length > 20) {
|
html += '<div class="listings-container">';
|
||||||
html += `<p class="more-info">... 외 ${data.listings.length - 20}건</p>`;
|
|
||||||
|
// 매물 카드 표시
|
||||||
|
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>';
|
html += '</div>';
|
||||||
|
|
@ -108,9 +115,14 @@ function displayResults(data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 실거래가 카드 생성
|
// 실거래가 카드 생성
|
||||||
function createListingCard(item) {
|
function createListingCard(item, isFiltered = false) {
|
||||||
let html = '<div class="listing-card">';
|
let html = '<div class="listing-card">';
|
||||||
|
|
||||||
|
// 순위 표시 (필터링된 경우)
|
||||||
|
if (isFiltered && item.rank) {
|
||||||
|
html += `<div class="rank-badge">TOP ${item.rank}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// 제목 (아파트명 또는 주소)
|
// 제목 (아파트명 또는 주소)
|
||||||
const title = item['아파트'] || item['법정동'] || '정보 없음';
|
const title = item['아파트'] || item['법정동'] || '정보 없음';
|
||||||
html += `<h4>${title}</h4>`;
|
html += `<h4>${title}</h4>`;
|
||||||
|
|
|
||||||
|
|
@ -273,3 +273,38 @@ header p {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
font-style: italic;
|
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
|
python-dotenv
|
||||||
requests
|
requests
|
||||||
pydantic
|
pydantic
|
||||||
|
python-multipart
|
||||||
Loading…
Reference in New Issue