From dd91ba406436ecd853aad2d89d3dd7e00a6ed336 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 27 Jan 2026 07:12:47 +0000 Subject: [PATCH 1/4] =?UTF-8?q?MapSy-AI=20=EB=B2=84=EC=A0=84=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20:=20docs=20:=20v1.0.7=20README=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?[skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7bf770d..4e1459d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # MapSy-AI -## 최신 버전 : v1.0.4 (2026-01-18) +## 최신 버전 : v1.0.7 (2026-01-27) [전체 버전 기록 보기](CHANGELOG.md) From ef880f8458261893d746f00a0277e637f95c42bc Mon Sep 17 00:00:00 2001 From: SUH SAECHAN Date: Tue, 27 Jan 2026 16:45:24 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EC=9D=B8=EC=8A=A4=ED=83=80=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=EC=A0=95=EB=B3=B4=EB=A5=BC=20=ED=86=B5=ED=95=9C=20?= =?UTF-8?q?=EC=A0=95=ED=99=95=ED=95=9C=20=EC=9E=A5=EC=86=8C=EB=AA=85=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=ED=95=84=EC=9A=94=20:=20refactor=20:=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=EC=B6=94=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B3=A0=EB=8F=84=ED=99=94=20https://github.com/MapSee-Lab/Map?= =?UTF-8?q?Sy-AI/issues/19?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/test_router.py | 110 ++++++++++++++++++++++++++++- src/models/integrated_search.py | 42 +++++++++++ src/services/modules/ollama_llm.py | 59 ++++++++++++---- 3 files changed, 198 insertions(+), 13 deletions(-) create mode 100644 src/models/integrated_search.py diff --git a/src/apis/test_router.py b/src/apis/test_router.py index 2d93fff..bdf54c5 100644 --- a/src/apis/test_router.py +++ b/src/apis/test_router.py @@ -18,6 +18,7 @@ geocode_with_nominatim ) from src.services.modules.ollama_llm import extract_place_names_with_ollama, OllamaPlaceResult +from src.models.integrated_search import SnsInfo, IntegratedPlaceSearchResponse from src.core.exceptions import CustomError logger = logging.getLogger(__name__) @@ -43,6 +44,11 @@ class LlmPlaceExtractRequest(BaseModel): caption: str = Field(..., description="인스타그램 게시물 본문 텍스트", min_length=1) +class IntegratedSearchRequest(BaseModel): + """통합 장소 검색 요청""" + url: str = Field(..., description="Instagram URL") + + @router.post("/scrape", status_code=200) async def scrape_url(request: ScrapeRequest): """ @@ -173,4 +179,106 @@ async def extract_place_names(request: LlmPlaceExtractRequest): result = await extract_place_names_with_ollama(request.caption) logger.info(f"장소명 추출 완료: {result.place_names}") - return result \ No newline at end of file + return result + + +@router.post("/integrated-place-search", response_model=IntegratedPlaceSearchResponse, status_code=200) +async def integrated_place_search(request: IntegratedSearchRequest): + """ + Instagram URL에서 장소 정보를 통합 추출 + + 전체 파이프라인: + 1. Instagram 파싱 → caption 추출 + 2. Ollama LLM → 장소명 추출 + 3. 네이버 지도 검색 → 상세 정보 + + - POST /api/test/integrated-place-search + - Body: {"url": "https://www.instagram.com/p/..."} + - 성공: 200 + IntegratedPlaceSearchResponse + - 실패: 400 (Instagram 파싱 실패) + + 네이버 지도 검색은 개별 실패 허용 (failed_searches에 기록) + """ + # Step 1: 요청 수신 + logger.info(f"[통합 검색] Step 1/5: 요청 수신 - url={request.url}") + + # Step 2: Instagram 파싱 + logger.info("[통합 검색] Step 2/5: Instagram 파싱 시작") + try: + sns_data = await route_and_scrape(request.url) + except HTTPException: + raise + except Exception as error: + logger.info(f"[통합 검색] Step 2/5: Instagram 파싱 실패 - {error}") + raise HTTPException(status_code=400, detail=f"Instagram 파싱 실패: {error}") + + # SNS 정보 구성 + sns_info = SnsInfo( + platform=sns_data.get("platform", "unknown"), + content_type=sns_data.get("content_type", "unknown"), + url=sns_data.get("url", request.url), + author=sns_data.get("author"), + caption=sns_data.get("caption"), + likes_count=sns_data.get("likes_count"), + comments_count=sns_data.get("comments_count"), + posted_at=sns_data.get("posted_at"), + hashtags=sns_data.get("hashtags", []), + og_image=sns_data.get("og_image"), + image_urls=sns_data.get("image_urls", []), + author_profile_image_url=sns_data.get("author_profile_image_url") + ) + + caption = sns_data.get("caption") or "" + logger.info(f"[통합 검색] Step 2/5: Instagram 파싱 완료 - author={sns_info.author}, caption 길이={len(caption)}") + + # Step 3: LLM 장소 추출 + logger.info(f"[통합 검색] Step 3/5: LLM 장소 추출 시작 - caption 길이={len(caption)}") + + if not caption.strip(): + logger.info("[통합 검색] Step 3/5: caption이 비어있어 장소 추출 스킵") + extracted_place_names = [] + has_places = False + else: + llm_result = await extract_place_names_with_ollama(caption) + extracted_place_names = llm_result.place_names + has_places = llm_result.has_places + + logger.info(f"[통합 검색] Step 3/5: LLM 장소 추출 완료 - 추출된 장소 수={len(extracted_place_names)}") + + # Step 4: 네이버 지도 검색 + place_details = [] + failed_searches = [] + + if extracted_place_names: + logger.info(f"[통합 검색] Step 4/5: 네이버 지도 검색 시작 - 검색할 장소 수={len(extracted_place_names)}") + scraper = NaverMapScraper() + + for index, place_name in enumerate(extracted_place_names, 1): + logger.info(f"[통합 검색] Step 4/5: 네이버 지도 검색 ({index}/{len(extracted_place_names)}) - query={place_name}") + try: + place_info = await scraper.search_and_scrape(place_name) + place_details.append(place_info) + logger.info(f"[통합 검색] Step 4/5: 검색 성공 - {place_info.name} ({place_info.place_id})") + except Exception as error: + logger.info(f"[통합 검색] Step 4/5: 검색 실패 - query={place_name}, error={error}") + failed_searches.append(place_name) + + logger.info(f"[통합 검색] Step 4/5: 네이버 지도 검색 완료 - 성공={len(place_details)}, 실패={len(failed_searches)}") + else: + logger.info("[통합 검색] Step 4/5: 추출된 장소가 없어 네이버 지도 검색 스킵") + + # Step 5: 결과 통합 + total_extracted = len(extracted_place_names) + total_found = len(place_details) + + logger.info(f"[통합 검색] Step 5/5: 결과 통합 완료 - 총 추출={total_extracted}, 총 발견={total_found}") + + return IntegratedPlaceSearchResponse( + sns_info=sns_info, + extracted_place_names=extracted_place_names, + has_places=has_places, + place_details=place_details, + total_extracted=total_extracted, + total_found=total_found, + failed_searches=failed_searches + ) \ No newline at end of file diff --git a/src/models/integrated_search.py b/src/models/integrated_search.py new file mode 100644 index 0000000..88b9fb0 --- /dev/null +++ b/src/models/integrated_search.py @@ -0,0 +1,42 @@ +"""src.models.integrated_search +통합 장소 검색 응답 모델 +Instagram → LLM → 네이버 지도 파이프라인 결과 +""" +from pydantic import BaseModel, Field + +from src.models.naver_place_info import NaverPlaceInfo + + +class SnsInfo(BaseModel): + """SNS 메타데이터""" + platform: str = Field(..., description="플랫폼 (instagram, youtube 등)") + content_type: str = Field(..., description="콘텐츠 타입 (post, reel, igtv 등)") + url: str = Field(..., description="원본 URL") + author: str | None = Field(default=None, description="작성자") + caption: str | None = Field(default=None, description="게시물 본문") + likes_count: int | None = Field(default=None, description="좋아요 수") + comments_count: int | None = Field(default=None, description="댓글 수") + posted_at: str | None = Field(default=None, description="게시 날짜") + hashtags: list[str] = Field(default_factory=list, description="해시태그 리스트") + og_image: str | None = Field(default=None, description="대표 이미지 URL") + image_urls: list[str] = Field(default_factory=list, description="이미지 URL 리스트") + author_profile_image_url: str | None = Field(default=None, description="작성자 프로필 이미지") + + +class IntegratedPlaceSearchResponse(BaseModel): + """통합 장소 검색 결과""" + + # SNS 정보 + sns_info: SnsInfo = Field(..., description="SNS 메타데이터") + + # LLM 추출 결과 + extracted_place_names: list[str] = Field(default_factory=list, description="LLM이 추출한 장소명 리스트") + has_places: bool = Field(default=False, description="장소 존재 여부") + + # 네이버 지도 검색 결과 + place_details: list[NaverPlaceInfo] = Field(default_factory=list, description="네이버 지도 검색 결과") + + # 처리 통계 + total_extracted: int = Field(default=0, description="추출된 장소 수") + total_found: int = Field(default=0, description="네이버에서 찾은 장소 수") + failed_searches: list[str] = Field(default_factory=list, description="검색 실패한 장소명") diff --git a/src/services/modules/ollama_llm.py b/src/services/modules/ollama_llm.py index 841574b..bee5494 100644 --- a/src/services/modules/ollama_llm.py +++ b/src/services/modules/ollama_llm.py @@ -45,19 +45,48 @@ class OllamaPlaceResult(BaseModel): # ============================================= # 프롬프트 템플릿 # ============================================= -PLACE_EXTRACTION_PROMPT = """다음 텍스트에서 장소명(가게명, 상호명, 식당명, 카페명, 관광지명 등)을 추출하세요. +PLACE_EXTRACTION_PROMPT = """당신은 인스타그램 게시물에서 **실제 방문할 수 있는 장소명**을 추출하는 전문가입니다. -장소명 예시: -- 스타벅스 종합운동장사거리점 -- 블루보틀 성수 -- 스시호 -- 사사노하 +## 당신의 임무 +텍스트를 읽고, 사람들이 **네이버 지도에서 검색해서 찾아갈 수 있는 장소명**을 추출하세요. -규칙: -1. 텍스트에 언급된 실제 장소명만 추출하세요. -2. 해시태그(#)가 붙어있어도 장소명이면 추출하세요. (#스시호 → 스시호) -3. 일반 명사(맛집, 초밥, 카페 등)는 장소명이 아닙니다. -4. 장소가 없으면 빈 배열 []을 반환하세요. +## 중요: 정확한 장소명 추출 +- 지점명이 있으면 **지점명까지 포함**해서 추출하세요 +- "스타벅스" (X) → "스타벅스 종합운동장역점" (O) +- "블루보틀" (X) → "블루보틀 성수" (O) + +## 예시 + +### 예시 1 +입력: "강남역 근처 파스타 맛집 #라라브레드 다녀왔어요! 분위기 좋고 맛있음" +출력: ["라라브레드"] +이유: "라라브레드"가 가게명. "강남역", "파스타", "맛집"은 장소명 아님 + +### 예시 2 +입력: "1. #스시호 -위치_서울 송파구 2. #멘야하나비 강남점" +출력: ["스시호", "멘야하나비 강남점"] +이유: 지점명이 있으면 포함. "송파구"는 주소일 뿐 + +### 예시 3 +입력: "스타벅스 종합운동장역점에서 커피 마시고 블루보틀 성수 갔다옴" +출력: ["스타벅스 종합운동장역점", "블루보틀 성수"] +이유: 지점명까지 포함된 전체 이름이 장소명 + +### 예시 4 +입력: "서울 카페 추천! 요즘 핫한 곳들 #성수동카페투어" +출력: [] +이유: 구체적인 가게명 없음. "성수동카페투어"는 해시태그 키워드 + +### 예시 5 +입력: "홍대 맛집 리스트 정리 중... 맛집, 카페, 술집 다 모아봄" +출력: [] +이유: "맛집", "카페", "술집"은 카테고리, 가게명 아님 + +## 주의사항 +- 해시태그(#)는 제거하고 반환 +- 인스타 계정(@username)은 가게명이 아님 +- 단독 지역명(송파구, 강남역)은 장소명 아님 +- 하지만 "스타벅스 강남역점"처럼 지점명의 일부면 포함 {caption} @@ -131,7 +160,13 @@ async def extract_place_names_with_ollama( # JSON 파싱 try: parsed = json.loads(content) - result = OllamaPlaceResult.model_validate(parsed) + parsed_place_names = parsed.get("place_names", []) + + # has_places는 place_names 길이로 자동 계산 (LLM 응답 무시) + result = OllamaPlaceResult( + place_names=parsed_place_names, + has_places=len(parsed_place_names) > 0 + ) logger.info(f"장소명 추출 성공: {result.place_names}") return result From f794daffaecd71f644ef65aaf7caa12cb334d7fb Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 27 Jan 2026 07:45:31 +0000 Subject: [PATCH 3/4] =?UTF-8?q?MapSy-AI=20=EB=B2=84=EC=A0=84=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EA=B4=80=EB=A6=AC:=20chore:=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=201.0.8=20[skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- version.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/version.yml b/version.yml index df64007..5a62bb6 100644 --- a/version.yml +++ b/version.yml @@ -34,11 +34,11 @@ # - 버전은 항상 높은 버전으로 자동 동기화됩니다 # =================================================================== -version: "1.0.7" -version_code: 23 # app build number +version: "1.0.8" +version_code: 24 # app build number project_type: "python" # spring, flutter, react, react-native, react-native-expo, node, python, basic metadata: - last_updated: "2026-01-27 07:10:52" + last_updated: "2026-01-27 07:45:31" last_updated_by: "Cassiiopeia" default_branch: "main" integrated_from: "SUH-DEVOPS-TEMPLATE" From 3ee498cc77353e7455a7b0b6bd7c64f3b3787bde Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 27 Jan 2026 07:51:45 +0000 Subject: [PATCH 4/4] =?UTF-8?q?MapSy-AI=20=EB=B2=84=EC=A0=84=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20:=20docs=20:=20v1.0.8=20=EB=A6=B4=EB=A6=AC=EC=A6=88?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?(PR=20#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.json | 35 ++++++++++++++++++++++++++++++++--- CHANGELOG.md | 20 ++++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.json b/CHANGELOG.json index 1a8f3a7..092b6d6 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,11 +1,40 @@ { "metadata": { - "lastUpdated": "2026-01-27T07:12:32Z", - "currentVersion": "1.0.7", + "lastUpdated": "2026-01-27T07:51:45Z", + "currentVersion": "1.0.8", "projectType": "python", - "totalReleases": 9 + "totalReleases": 10 }, "releases": [ + { + "version": "1.0.8", + "project_type": "python", + "date": "2026-01-27", + "pr_number": 21, + "raw_summary": "## Summary by CodeRabbit\n\n## 릴리스 노트\n\n* **새로운 기능**\n * SNS 콘텐츠에서 통합 장소 검색 기능 추가\n * Instagram 링크를 기반으로 장소명 자동 추출 및 지도 검색 수행\n\n* **개선 사항**\n * 장소명 추출 알고리즘 정확도 향상\n\n* **문서**\n * 버전 1.0.8로 업데이트", + "parsed_changes": { + "새로운_기능": { + "title": "새로운 기능", + "items": [ + "SNS 콘텐츠에서 통합 장소 검색 기능 추가", + "Instagram 링크를 기반으로 장소명 자동 추출 및 지도 검색 수행" + ] + }, + "개선_사항": { + "title": "개선 사항", + "items": [ + "장소명 추출 알고리즘 정확도 향상" + ] + }, + "문서": { + "title": "문서", + "items": [ + "버전 1.0.8로 업데이트" + ] + } + }, + "parse_method": "markdown" + }, { "version": "1.0.7", "project_type": "python", diff --git a/CHANGELOG.md b/CHANGELOG.md index a21166b..96783c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,23 @@ # Changelog -**현재 버전:** 1.0.7 -**마지막 업데이트:** 2026-01-27T07:12:32Z +**현재 버전:** 1.0.8 +**마지막 업데이트:** 2026-01-27T07:51:45Z + +--- + +## [1.0.8] - 2026-01-27 + +**PR:** #21 + +**새로운 기능** +- SNS 콘텐츠에서 통합 장소 검색 기능 추가 +- Instagram 링크를 기반으로 장소명 자동 추출 및 지도 검색 수행 + +**개선 사항** +- 장소명 추출 알고리즘 정확도 향상 + +**문서** +- 버전 1.0.8로 업데이트 ---