Skip to content

Commit 9abc86d

Browse files
committed
백엔드의 /api/ai/callback 연동 API 개발 필요 : feat :기본 콜백 함수 구현 #22
1 parent 93f7854 commit 9abc86d

File tree

9 files changed

+425
-152
lines changed

9 files changed

+425
-152
lines changed

src/apis/place_router.py

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,18 @@
11
"""src.apis.place_router
2-
장소 추출 API 라우터 (Spring의 PlaceController와 유사한 역할)
2+
장소 추출 API 라우터
33
"""
44
import asyncio
55
import logging
6-
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
7-
import requests
6+
from fastapi import APIRouter, Depends
87

9-
from src.models import PlaceExtractionRequest, PlaceExtractionResponse, ExtractionState
10-
from src.services.workflow import run_image_workflow, run_media_workflow
11-
from src.services.modules.llm import get_llm_response
12-
# from src.services.workflow import demo_process # FIXME: 임시로 데모 워크플로우 사용
8+
from src.models import PlaceExtractionRequest
139
from src.utils.common import verify_api_key
14-
from src.core.exceptions import CustomError
15-
from src.core.config import settings
1610
from src.services.background_tasks import process_extraction_in_background
1711

1812
logger = logging.getLogger(__name__)
1913
router = APIRouter(prefix="/api", tags=["AI 서버 API"])
2014

15+
2116
@router.post("/extract-places", status_code=200)
2217
async def extract_places(
2318
request: PlaceExtractionRequest,
@@ -27,9 +22,15 @@ async def extract_places(
2722
인증(API Key): 필요
2823
2924
기능
30-
SNS 콘텐츠 URL과 contentId를 입력받아 장소 추출 파이프라인을 비동기 방식으로 실행합니다.
25+
SNS 콘텐츠 URL과 contentId를 입력받아 통합 장소 검색 파이프라인을 비동기 방식으로 실행합니다.
3126
요청은 즉시 200 OK를 반환하며, 실제 처리 결과는 백엔드의 `/api/ai/callback`으로 전송됩니다.
3227
28+
------------------------------------------------------------
29+
파이프라인
30+
1. SNS 스크래핑 (Playwright) - 메타데이터 및 캡션 추출
31+
2. LLM 장소명 추출 (Ollama) - 캡션에서 장소명 추출
32+
3. 네이버 지도 검색 - 장소 상세 정보 획득
33+
3334
------------------------------------------------------------
3435
요청 파라미터 (PlaceExtractionRequest)
3536
- contentId (UUID): 콘텐츠 고유 식별자
@@ -43,27 +44,75 @@ async def extract_places(
4344
"message": "Processing started"
4445
}
4546
```
47+
4648
------------------------------------------------------------
47-
비동기 처리 방식
48-
- 스크래핑, STT, LLM 분석 등 전체 파이프라인은 Background Task에서 처리됩니다.
49-
- 파이프라인 오류 발생 시에도 본 엔드포인트는 항상 200 OK를 반환하고, 오류는 내부 로그로만 기록됩니다.
49+
콜백 응답 (비동기 - /api/ai/callback)
50+
51+
SUCCESS 시:
52+
```json
53+
{
54+
"contentId": "UUID",
55+
"resultStatus": "SUCCESS",
56+
"snsInfo": {
57+
"platform": "INSTAGRAM",
58+
"contentType": "reel",
59+
"url": "...",
60+
"author": "username",
61+
"caption": "게시물 본문",
62+
"likesCount": 1234,
63+
"commentsCount": 56,
64+
"hashtags": ["태그1", "태그2"],
65+
"thumbnailUrl": "...",
66+
"imageUrls": ["..."],
67+
"authorProfileImageUrl": "..."
68+
},
69+
"placeDetails": [
70+
{
71+
"placeId": "11679241",
72+
"name": "장소명",
73+
"latitude": 37.5112,
74+
"longitude": 127.0867,
75+
"address": "주소",
76+
"category": "카테고리",
77+
"rating": 4.42,
78+
"businessStatus": "영업 중",
79+
"phoneNumber": "02-xxx-xxxx",
80+
"naverMapUrl": "https://map.naver.com/..."
81+
}
82+
],
83+
"statistics": {
84+
"extractedPlaceNames": ["장소1", "장소2"],
85+
"totalExtracted": 2,
86+
"totalFound": 1,
87+
"failedSearches": ["장소2"]
88+
}
89+
}
90+
```
91+
92+
FAILED 시:
93+
```json
94+
{
95+
"contentId": "UUID",
96+
"resultStatus": "FAILED",
97+
"errorMessage": "에러 메시지"
98+
}
99+
```
100+
50101
------------------------------------------------------------
51102
에러 코드
52-
- 인증 실패 시:
53-
- 401 UNAUTHORIZED (API Key 누락 또는 불일치)
54-
103+
- 인증 실패 시: 401 UNAUTHORIZED (API Key 누락 또는 불일치)
55104
"""
56105

57106
logger.info(
58107
f"extract-places 요청 수신: contentId={request.contentId}, url={request.snsUrl}"
59108
)
60109

61-
# 1. 비동기 백그라운드 처리 시작
110+
# 비동기 백그라운드 처리 시작
62111
asyncio.create_task(
63112
process_extraction_in_background(request)
64113
)
65114

66-
# 2. 즉시 응답 반환
115+
# 즉시 응답 반환
67116
return {
68117
"received": True,
69118
"message": "Processing started"

src/models/__init__.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
"""src.models
22
API 요청/응답에 사용되는 Pydantic 스키마 정의
33
"""
4-
from src.models.place_extraction_dict import PlaceExtractionDict
5-
from src.models.extracted_data_dict import ExtractedDataDict
6-
from src.models.extraction_state import ExtractionState
74
from src.models.place_extraction_request import PlaceExtractionRequest
8-
from src.models.place_extraction_response import PlaceExtractionResponse
9-
from src.models.content_info import ContentInfo
5+
from src.models.callback_request import (
6+
AiCallbackRequest,
7+
SnsInfoCallback,
8+
PlaceDetailCallback,
9+
ExtractionStatistics
10+
)
11+
from src.models.naver_place_info import NaverPlaceInfo
12+
from src.models.integrated_search import IntegratedPlaceSearchResponse, SnsInfo
1013

1114
__all__ = [
12-
"PlaceExtractionDict",
13-
"ExtractedDataDict",
14-
"ExtractionState",
15+
# 요청 모델
1516
"PlaceExtractionRequest",
16-
"PlaceExtractionResponse",
17-
"ContentInfo",
17+
# 콜백 모델
18+
"AiCallbackRequest",
19+
"SnsInfoCallback",
20+
"PlaceDetailCallback",
21+
"ExtractionStatistics",
22+
# 장소 정보 모델
23+
"NaverPlaceInfo",
24+
# 통합 검색 모델
25+
"IntegratedPlaceSearchResponse",
26+
"SnsInfo",
1827
]
19-

src/models/callback_request.py

Lines changed: 93 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,109 @@
11
"""src.models.callback_request.py
2-
AI -> 백엔드 요청 DTO
2+
AI -> 백엔드 콜백 요청 DTO
3+
4+
통합 장소 검색 결과를 백엔드에 전달하기 위한 모델들입니다.
35
"""
46

57
from pydantic import BaseModel, Field, model_validator
68
from typing import List, Literal, Optional
79
from uuid import UUID
8-
from src.models.content_info import ContentInfo
9-
from src.models.place_extraction_dict import PlaceExtractionDict
10+
11+
12+
class SnsInfoCallback(BaseModel):
13+
"""SNS 콘텐츠 메타데이터 (콜백용)"""
14+
platform: Literal["INSTAGRAM", "YOUTUBE", "YOUTUBE_SHORTS", "TIKTOK", "FACEBOOK", "TWITTER"] = Field(
15+
..., description="SNS 플랫폼"
16+
)
17+
contentType: str = Field(..., description="콘텐츠 타입 (post, reel, video, shorts 등)")
18+
url: str = Field(..., description="원본 URL")
19+
author: Optional[str] = Field(default=None, description="작성자")
20+
caption: Optional[str] = Field(default=None, description="게시물 본문")
21+
likesCount: Optional[int] = Field(default=None, description="좋아요 수")
22+
commentsCount: Optional[int] = Field(default=None, description="댓글 수")
23+
postedAt: Optional[str] = Field(default=None, description="게시 날짜")
24+
hashtags: List[str] = Field(default_factory=list, description="해시태그 리스트")
25+
thumbnailUrl: Optional[str] = Field(default=None, description="대표 이미지 URL")
26+
imageUrls: List[str] = Field(default_factory=list, description="이미지 URL 리스트")
27+
authorProfileImageUrl: Optional[str] = Field(default=None, description="작성자 프로필 이미지 URL")
28+
29+
30+
class PlaceDetailCallback(BaseModel):
31+
"""네이버 지도 장소 상세 정보 (콜백용)"""
32+
# 필수 정보
33+
placeId: str = Field(..., description="네이버 Place ID")
34+
name: str = Field(..., description="장소명")
35+
36+
# 위치 정보
37+
latitude: Optional[float] = Field(default=None, description="위도")
38+
longitude: Optional[float] = Field(default=None, description="경도")
39+
address: Optional[str] = Field(default=None, description="주소")
40+
roadAddress: Optional[str] = Field(default=None, description="도로명 주소")
41+
42+
# 카테고리/설명
43+
category: Optional[str] = Field(default=None, description="카테고리")
44+
description: Optional[str] = Field(default=None, description="한줄 설명")
45+
46+
# 평점/리뷰
47+
rating: Optional[float] = Field(default=None, description="별점 (0.0 ~ 5.0)")
48+
visitorReviewCount: Optional[int] = Field(default=None, description="방문자 리뷰 수")
49+
blogReviewCount: Optional[int] = Field(default=None, description="블로그 리뷰 수")
50+
51+
# 영업 정보
52+
businessStatus: Optional[str] = Field(default=None, description="영업 상태")
53+
businessHours: Optional[str] = Field(default=None, description="영업 시간 요약")
54+
openHoursDetail: List[str] = Field(default_factory=list, description="요일별 상세 영업시간")
55+
holidayInfo: Optional[str] = Field(default=None, description="휴무일 정보")
56+
57+
# 연락처/링크
58+
phoneNumber: Optional[str] = Field(default=None, description="전화번호")
59+
homepageUrl: Optional[str] = Field(default=None, description="홈페이지 URL")
60+
naverMapUrl: Optional[str] = Field(default=None, description="네이버 지도 URL")
61+
reservationAvailable: bool = Field(default=False, description="예약 가능 여부")
62+
63+
# 부가 정보
64+
subwayInfo: Optional[str] = Field(default=None, description="지하철 정보")
65+
directionsText: Optional[str] = Field(default=None, description="찾아가는 길")
66+
amenities: List[str] = Field(default_factory=list, description="편의시설 목록")
67+
keywords: List[str] = Field(default_factory=list, description="키워드/태그")
68+
tvAppearances: List[str] = Field(default_factory=list, description="TV 방송 출연 정보")
69+
menuInfo: List[str] = Field(default_factory=list, description="대표 메뉴")
70+
71+
# 이미지
72+
imageUrl: Optional[str] = Field(default=None, description="대표 이미지 URL")
73+
imageUrls: List[str] = Field(default_factory=list, description="이미지 URL 목록")
74+
75+
76+
class ExtractionStatistics(BaseModel):
77+
"""추출 처리 통계"""
78+
extractedPlaceNames: List[str] = Field(default_factory=list, description="LLM이 추출한 장소명 리스트")
79+
totalExtracted: int = Field(default=0, description="LLM이 추출한 장소 수")
80+
totalFound: int = Field(default=0, description="네이버 지도에서 찾은 장소 수")
81+
failedSearches: List[str] = Field(default_factory=list, description="검색 실패한 장소명")
82+
1083

1184
class AiCallbackRequest(BaseModel):
85+
"""AI 서버 → 백엔드 콜백 요청"""
1286
contentId: UUID = Field(..., description="Content UUID")
1387
resultStatus: Literal["SUCCESS", "FAILED"] = Field(..., description="처리 결과 상태")
14-
snsPlatform: Literal["INSTAGRAM", "YOUTUBE", "YOUTUBE_SHORTS", "TIKTOK", "FACEBOOK", "TWITTER"] = Field(
15-
..., description="SNS 플랫폼"
16-
)
17-
contentInfo: Optional[ContentInfo] = Field(default=None, description="콘텐츠 정보 (SUCCESS 시 필수)")
18-
places: List[PlaceExtractionDict] = Field(default_factory=list, description="장소 정보 리스트")
19-
rawData: Optional[dict] = Field(default=None, description="AI 추출 원본 데이터")
88+
89+
# SNS 정보
90+
snsInfo: Optional[SnsInfoCallback] = Field(default=None, description="SNS 콘텐츠 정보")
91+
92+
# 장소 정보 리스트
93+
placeDetails: List[PlaceDetailCallback] = Field(default_factory=list, description="장소 상세 정보 리스트")
94+
95+
# 추출 통계
96+
statistics: Optional[ExtractionStatistics] = Field(default=None, description="추출 처리 통계")
97+
98+
# 에러 정보 (FAILED 시)
99+
errorMessage: Optional[str] = Field(default=None, description="실패 시 에러 메시지")
20100

21101
@model_validator(mode="after")
22102
def validate_success_payload(cls, model: "AiCallbackRequest") -> "AiCallbackRequest":
23103
if model.resultStatus == "SUCCESS":
24-
if model.contentInfo is None:
25-
raise ValueError("contentInfo is required when resultStatus is SUCCESS")
104+
if model.snsInfo is None:
105+
raise ValueError("snsInfo is required when resultStatus is SUCCESS")
26106
else:
27-
model.places = []
107+
# FAILED 시 placeDetails 비우기
108+
model.placeDetails = []
28109
return model

src/models/content_info.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""src.models.content_info
22
콘텐츠 정보 DTO
3+
4+
DEPRECATED: 이 모듈은 더 이상 사용되지 않습니다.
5+
새로운 콜백 모델은 src.models.callback_request.SnsInfoCallback을 사용하세요.
36
"""
47
from pydantic import BaseModel, Field
58
from uuid import UUID

src/models/place_extraction_dict.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""src.models.place_extraction_dict
22
장소 추출 데이터 스키마
3+
4+
DEPRECATED: 이 모듈은 더 이상 사용되지 않습니다.
5+
새로운 장소 상세 정보 모델은 src.models.callback_request.PlaceDetailCallback을 사용하세요.
36
"""
47
from pydantic import BaseModel, Field
58
from typing import Optional

src/models/place_extraction_response.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""src.models.place_extraction_response
22
장소 추출 응답 DTO
3+
4+
DEPRECATED: 이 모듈은 더 이상 사용되지 않습니다.
5+
새로운 콜백 모델은 src.models.callback_request.AiCallbackRequest를 사용하세요.
36
"""
47
from pydantic import BaseModel, Field
58
from uuid import UUID

0 commit comments

Comments
 (0)