다크 모드
아키텍처
전체 아키텍처
처리 흐름 상세
이미지 분석 파이프라인
FallbackModel
이미지 분석에 pydantic-ai의 FallbackModel을 사용하여 모델 장애에 대비합니다.
python
# 파이프(|) 구분 모델 목록에서 FallbackModel 생성
model_names = [name.strip() for name in settings.image_analysis_models.split("|")]
# 기본값: ["google/gemini-3-flash-preview", "google/gemini-2.5-flash"]
models = [OpenAIChatModel(name, provider=provider) for name in model_names]
self.model = FallbackModel(*models) if len(models) > 1 else models[0]첫 번째 모델(gemini-3-flash-preview)이 실패하면 자동으로 두 번째 모델(gemini-2.5-flash)로 전환됩니다. 환경변수 IMAGE_ANALYSIS_MODELS로 런타임에 모델을 변경할 수 있습니다.
재시도 전략
tenacity 라이브러리로 네트워크 오류에 대한 재시도를 수행합니다.
| 설정 | 값 |
|---|---|
| 최대 시도 횟수 | 3회 |
| 백오프 | 지수 (min 2초, max 10초) |
| 재시도 대상 | httpx.ConnectError, httpx.TimeoutException, httpx.RemoteProtocolError, APIConnectionError, APITimeoutError |
| HTTP 타임아웃 | 120초 |
| 커넥션 제한 | max_keepalive=5, max_connections=10 |
병렬 처리
asyncio.gather로 모든 이미지를 동시에 분석합니다. 개별 이미지 실패는 ImageAnalysisResult.error에 기록되고, 성공한 이미지만으로 스토리 생성을 진행합니다.
python
async def analyze_images(self, media_items, loop_metadata, locale=None):
locale_config = get_locale_config(locale, timezone=loop_metadata.timezone)
tasks = [self.analyze_image(item, loop_metadata, locale_config) for item in media_items]
results = await asyncio.gather(*tasks)
# 개별 실패는 error 필드에 기록, 예외를 전파하지 않음
return resultsAgent 구조
pydantic-ai Agent는 두 레벨의 프롬프트로 구성됩니다:
| 프롬프트 | 위치 | 역할 |
|---|---|---|
system_prompt | Agent 생성 시 고정 | 구조적 분석 지침 (언어 중립) |
instructions | agent.run() 시 동적 전달 | 로케일별 문화/톤 지침 |
user prompt에는 Loop 메타데이터(제목, 참가자명), 태깅된 사용자, 댓글 등의 컨텍스트가 포함됩니다.
스토리 생성 파이프라인
모델 구성
스토리 생성은 단일 모델(anthropic/claude-3.5-sonnet)을 사용합니다. FallbackModel을 사용하지 않습니다.
프롬프트 구조
| 프롬프트 | 내용 |
|---|---|
story_system_prompt | 출력 포맷 규칙 (text/media 블록 교차 패턴, 언어 중립) |
story_instructions | 로케일별 톤/스타일 (예: 한국어 "20대 인스타 일상글" 톤) |
story_user_prompt_template | 모임 정보 + 사진 설명 → 스토리 작성 요청 |
instructions는 story_system_prompt + story_instructions를 결합하여 전달됩니다.
출력 구조
pydantic-ai Agent가 structured output으로 StoryGenerationOutput을 반환합니다:
python
class StoryGenerationOutput(BaseModel):
title: str # 최대 50자, 캐치한 제목
preview_text: str # 2-3문장, 최대 150자
blocks: list[StoryBlockOutput] # text/media 블록 배열블록 재배치 로직
Claude가 연속 media 블록을 생성하는 경우를 감지하여 자동으로 text-media 교차 패턴으로 재배치합니다.
python
def _convert_to_story_content(self, output, media_items):
# 연속 media 블록 감지
if self._has_consecutive_media_blocks(output.blocks):
logger.warning("Consecutive media blocks detected. Reordering...")
return self._reorder_blocks(output.blocks, media_items)
return self._process_blocks_in_order(output.blocks, media_items)재배치 전략:
- text 블록과 media 블록을 분리
- media 블록을
image_index순으로 정렬 - text 블록을 media 블록 사이에 균등 분배 (text → media → text → media 패턴)
이미지 그룹핑 알고리즘
group_and_reorder 함수가 타임스탬프 기반으로 이미지를 시간순으로 재배치합니다.
python
def group_and_reorder(
media_items: list[MediaItem],
analysis_results: list[ImageAnalysisResult],
gap_minutes: int = 30,
) -> tuple[list[MediaItem], list[ImageAnalysisResult]]:
# ...타임스탬프 분리
이미지를 타임스탬프가 있는 것과 없는 것으로 분리합니다. 타임스탬프가 전혀 없으면 원래 순서를 유지합니다.
시간순 정렬
타임스탬프가 있는 이미지를 촬영 시간 순으로 정렬합니다.
갭 기반 그룹핑
인접 이미지 간 시간 차이가 gap_minutes(기본 30분) 이상이면 새 그룹을 시작합니다.
타임스탬프 없는 이미지 추가
타임스탬프가 없는 이미지는 원래 순서를 유지하여 마지막 그룹으로 추가합니다.
평탄화
모든 그룹을 순서대로 평탄화하여 최종 정렬된 리스트를 반환합니다.
다국어 지원 아키텍처
LocaleConfig
LocaleConfig 데이터클래스가 각 언어별 프롬프트, 레이블, 날짜 포맷을 완전 분리하여 관리합니다.
| 필드 카테고리 | 필드 수 | 용도 |
|---|---|---|
| 스토리 생성 프롬프트 | 3 | system_prompt, instructions, user_prompt_template |
| 이미지 분석 프롬프트 | 2 | instructions, user_prompt_prefix |
| 로컬라이즈 레이블 | 10 | "모임 제목", "참가자" 등 프롬프트 내 레이블 |
| 날짜 포맷 | 1 | strftime 포맷 문자열 |
resolve_locale 로직
| 우선순위 | 방법 | 예시 |
|---|---|---|
| 1 | 명시적 locale 값 | "ko", "ja", "en" |
| 2 | locale 태그의 언어 prefix | "en-US" → en, "ko_KR" → ko |
| 3 | timezone fallback (레거시) | Asia/Seoul → ko, Asia/Tokyo → ja, 기타 → en |
에러 처리 흐름
에러 처리 계층
부분 이미지 실패 처리
개별 이미지 분석 실패는 전체 처리를 중단하지 않습니다. ImageAnalysisResult.error에 에러를 기록하고, 성공한 이미지만으로 스토리 생성을 진행합니다. 스토리 생성 프롬프트에서 실패한 이미지는 [이미지 분석 실패] 레이블로 표시됩니다.
DLQ 핸들러
dlqWorker는 3회 실패한 메시지를 수신하여 truloop-core API에 해당 Story를 FAILED로 마킹합니다.
python
def dlq_handler(event, context):
for record in event.get("Records", []):
body = json.loads(record["body"])
story_eid = body.get("story_eid")
# Story를 FAILED로 마킹
asyncio.run(api_client.update_story_failure(
request_id=body.get("request_id", "unknown"),
story_eid=story_eid,
error_message="Story generation failed after maximum retries",
))truloop-core 연동
Internal API 클라이언트
TruloopCoreClient가 truloop-core의 Internal API와 통신합니다.
| 설정 | 값 |
|---|---|
| 인증 | Authorization: Bearer {INTERNAL_API_KEY} |
| 타임아웃 | 전체 30초, 연결 10초 |
| 엔드포인트 | POST /core/internal/stories/{story_eid}/requests/{request_id} |
성공과 실패 모두 동일한 엔드포인트를 사용하며, status 필드(completed / failed)로 구분합니다.
주의
update_story_failure 메서드의 API 호출 실패 시 예외를 전파하지 않습니다 (except 블록에서 로그만 기록). 이는 API 보고 실패가 Lambda 실패로 이어지지 않도록 의도한 설계이지만, handler.py에서는 이 메서드의 실패를 별도로 감지하여 success=False를 반환합니다.
변경 이력
| 날짜 | 내용 |
|---|---|
| 2026-03-11 | 최초 작성 — truloop-story 아키텍처 상세 문서 |