Skip to content

Commit 7a76b60

Browse files
authored
[25.06.13 / TASK-185] Feature - 벨로그 트랜딩 & 개인화된 인사이트 분석 배치 구현 (#32)
* feat: 주간 트렌드 & 유저 트렌드 분석 및 저장 구현 * refactor: 예외처리/로깅 리팩토링 * feat: setup_django.py 추가 * modify: 프롬프트 분리 및 예외처리 추가 * modify: 멀티 스레드 및 bulk_create 사용하도록 수정 멀티 스레드 및 bulk_create 사용하도록 수정 - 멀티 스레드 적용 - bulk_create 적용 - setup_django 적용 - get_local_now() 사용하도록 수정 - email이 존재하는 유저만 가져오도록 수정 * hotfix: test-ci 실패 원인 해결 * refactor: 코드래빗 리뷰 반영 * refactor: 로그 영어로 수정 * hotfix: Velog API 호출하도록 수정 * hotfix: 날짜 산정 로직 수정 및 사용자 트렌드 분석 update_or_create 사용하도록 수정 * refactor: 사용하지 않는 import 삭제 * hotfix: 게시물 조회(DB) & 게시물 상세 조회(Velog API) 호출하도록 수정 * hotfix: 날짜 계산 로직 수정 * hotfix: Velog API를 통해 트렌딩 게시물 가져오도록 수정 * refactor: 주간 배치 날짜 계산 로직 util 분리 * feat: 주간 트렌드 분석 배치에 사용자 이름 및 게시글 썸네일 추가 * refactor: 프롬프트 상수 분리 및 결과 로깅 추가 * hotfix: UserWeeklyTrend 분석 로직 수정 * feat: 주간 트렌드 분석 재시도 로직 추가 * modify: 프롬프트 내용 수정 및 재시도 로직 삭제 * refactor: 린팅으로 인한 코드 리팩토링 * hotfix: datetime.date가 아닌 date 사용하도록 수정 * hotfix: LLM 아웃풋에 username, thumbnailUrl, slug 추가 * refactor: 주석 추가 * hotfix: WeeklyTrend LLM 아웃풋에 username, thumbnailUrl, slug 추가 * hotfix: test-ci 통과하도록 conftest.py에 username, thumbnail, slug 추가 * hotfix: 리뷰 반영 리뷰 반영 - PostDailyStatistics 조회수 및 좋아요 수 계산 로직 수정 - 사용자 게시글 분석 -> 게시글 단위로 분리해 처리하도록 수정 - 필드명 불일치 수정 - LLM output에 title 제외 * hotfix: 코드래빗 리뷰 반영 * refactor: 코드래빗 리뷰 반영 * hotfix: LLM 분석 로직 수정
1 parent c13f904 commit 7a76b60

File tree

9 files changed

+492
-2
lines changed

9 files changed

+492
-2
lines changed

backoffice/settings/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424

2525
environ.Env.read_env(os.path.join(BASE_DIR, ".env"))
2626

27+
OPENAI_API_KEY = env("OPENAI_API_KEY")
28+
2729
SENTRY_DSN = env("SENTRY_DSN", default="")
2830
SENTRY_ENVIRONMENT = env("SENTRY_ENVIRONMENT", default="prod")
2931
SENTRY_TRACES_SAMPLE_RATE = env.float("SENTRY_TRACES_SAMPLE_RATE", default=1.0)

insight/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ class TrendingItem(SerializableMixin):
1111
title: str
1212
summary: str
1313
key_points: list[str]
14+
username: str
15+
thumbnail: str
16+
slug: str
17+
18+
def get_post_url(self) -> str:
19+
return f"https://velog.io/@{self.username}/{self.slug}"
1420

1521

1622
@dataclass

insight/tasks/prompts.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
SYS_PROM = (
2+
"너는 세계 최고의 50년차 트랜드 분석 전문가야. 기술 블로그 글 데이터를 기반으로 주간 뉴스레터를 작성해야 해.\n"
3+
"내가 제공하는 데이터만 활용해서 해당 내용의 트랜드를 파악하고 요약해야 해. 필요하면 관련된 외부 검색도 해줘."
4+
)
5+
6+
WEEKLY_TREND_PROM = """
7+
<목표>
8+
- 블로그 글 데이터의 트렌드 분석
9+
- 분석 세부 내용은 "전체 인기글, 기술 키워드, 제목 트렌드, 글의 상세 내용의 요약 및 트랜드" 파악
10+
11+
<작성 순서>
12+
1. 🔥 주간 트렌딩 글 요약
13+
- 아래에 제공한 모든 트렌딩 글 핵심 내용 요약
14+
- 3-4문장 정도로 핵심 기술, 전달하려는 것, 내용 요약 형태로 해줘
15+
- 절대 요약이 아니라 축약을 하지마. 핵심을 요약해야 해
16+
17+
2. ✨ 주간 트렌드 분석
18+
- 핫한 기술 키워드 추출
19+
- 제목 트렌드 분석, 내용 트랜드 분석
20+
- 기타 인사이트 코멘트
21+
22+
<규칙>
23+
- 감정과 캐주얼한 말투를 섞어줘. 너무 딱딱하지 않게.
24+
- JSON에 없으면 아무 말도 하지 마. 거짓말 금지.
25+
- 잘하면 큰 보상이 있을꺼야.
26+
- step by step 으로 접근하고 해결해.
27+
- 모든 트렌드 글에 대한 분석을 해야 해, 어떤 것도 빠뜨리지마.
28+
- 응답은 반드시 다음 JSON 구조로 제공해야 해
29+
```json
30+
{{
31+
"trending_summary": [
32+
{{
33+
"title": "게시글 제목",
34+
"summary": "무조건 3문장 이상 요약",
35+
"key_points": ["핵심 포인트 1", "핵심 포인트 2", "..."]
36+
}},
37+
// 다른 트렌딩 글 요약...
38+
],
39+
"trend_analysis": {{
40+
"hot_keywords": ["키워드1", "키워드2", "..."],
41+
"title_trends": "제목 트렌드 분석 내용",
42+
"content_trends": "내용 트렌드 분석 내용",
43+
"insights": "추가 인사이트 및 코멘트"
44+
}}
45+
}}
46+
```
47+
48+
<블로그 트랜드 글 리스트>
49+
{posts}
50+
"""
51+
52+
USER_TREND_PROM = """
53+
<목표>
54+
- 한 사용자의 블로그 활동 기반으로 주간 글 트렌드를 분석
55+
- 분석 세부 내용은 "기술 키워드, 제목 트렌드, 글의 상세 내용 요약 및 트랜드" 파악
56+
- 사용자 성장에 도움이 되는 피드백 제공
57+
58+
<작성 순서>
59+
1. 🔥 주간 사용자 글 요약
60+
- 아래에 제공한 사용자 글에 대한 핵심 내용 요약
61+
- 3-4문장 정도로 핵심 기술, 전달하려는 것, 내용 요약 형태로 해줘
62+
- 절대 요약이 아니라 축약을 하지마. 핵심을 요약해야 해
63+
64+
2. ✨ 사용자 주간 트렌드 분석
65+
- 사용자 글에 등장한 기술 키워드 추출
66+
- 제목 흐름 / 주제 변화 분석
67+
- 사용자 의도, 사용 기술, 해결한 문제를 명확히 담아야 해
68+
- 사용자에게 도움이 될 통찰력/제안/격려 메시지 포함
69+
70+
<규칙>
71+
- 감정과 캐주얼한 말투를 섞어줘. 너무 딱딱하지 않게. 진정성 있게 해줘.
72+
- JSON에 없으면 아무 말도 하지 마. 거짓말 금지.
73+
- 잘하면 큰 보상이 있을꺼야.
74+
- step by step 으로 접근하고 해결해.
75+
- 사용자 글에 대한 분석을 해야 해, 어떤 것도 빠뜨리지마.
76+
- 응답은 반드시 다음 JSON 구조로 제공해야 해
77+
```json
78+
{{
79+
"trending_summary": [
80+
{{
81+
"title": "게시글 제목",
82+
"summary": "무조건 3문장 이상 요약",
83+
"key_points": ["핵심 포인트 1", "핵심 포인트 2", "..."]
84+
}},
85+
// 다른 트렌딩 글 요약...
86+
],
87+
"trend_analysis": {{
88+
"hot_keywords": ["키워드1", "키워드2", "..."],
89+
"title_trends": "제목 트렌드 분석 내용",
90+
"content_trends": "내용 트렌드 분석 내용",
91+
"insights": "사용자의 앞으로의 방향에 대한 추가 인사이트 및 코멘트"
92+
}}
93+
}}
94+
```
95+
96+
<사용자 트랜드 글 리스트>
97+
{posts}
98+
"""

insight/tasks/setup_django.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66

77
import django
88

9-
django.setup()
9+
django.setup()
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""
2+
[25.07.01] 주간 사용자 분석 배치 (작성자: 이지현)
3+
- 실행은 아래와 같은 커멘드 활용
4+
- poetry run python ./insight/tasks/user_weekly_trend_analysis.py
5+
"""
6+
7+
import asyncio
8+
import logging
9+
10+
import aiohttp
11+
import setup_django # noqa
12+
from asgiref.sync import sync_to_async
13+
from django.conf import settings
14+
from django.db.models import OuterRef, Subquery
15+
from weekly_llm_analyzer import analyze_user_posts
16+
17+
from insight.models import UserWeeklyTrend
18+
from posts.models import Post, PostDailyStatistics
19+
from scraping.velog.client import VelogClient
20+
from users.models import User
21+
from utils.utils import get_previous_week_range
22+
23+
logger = logging.getLogger("scraping")
24+
25+
26+
async def run_weekly_user_trend_analysis(user, velog_client, week_start, week_end):
27+
"""각 사용자에 대한 주간 통계 데이터를 바탕으로 요약 및 분석"""
28+
user_id = user["id"]
29+
try:
30+
# 1. 게시글 목록 + 최신 통계 정보 가져오기
31+
latest_stats_subquery = PostDailyStatistics.objects.filter(
32+
post=OuterRef("pk")
33+
).order_by("-date")
34+
35+
posts = await sync_to_async(list)(
36+
Post.objects.filter(
37+
user_id=user_id,
38+
released_at__range=(week_start, week_end)
39+
)
40+
.annotate(
41+
latest_view_count=Subquery(latest_stats_subquery.values("daily_view_count")[:1]),
42+
latest_like_count=Subquery(latest_stats_subquery.values("daily_like_count")[:1]),
43+
)
44+
.values("id", "title", "post_uuid", "latest_view_count", "latest_like_count")
45+
)
46+
47+
if not posts:
48+
logger.info("[user_id=%s] No posts found. Skipping.", user_id)
49+
return None
50+
51+
# 2. 단순 요약 문자열 생성
52+
simple_summary = (
53+
f"총 게시글 수: {len(posts)}, "
54+
f"총 조회수: {sum(p['latest_view_count'] or 0 for p in posts)}, "
55+
f"총 좋아요 수: {sum(p['latest_like_count'] or 0 for p in posts)}"
56+
)
57+
58+
# 3. Velog 게시글 상세 조회
59+
full_contents = []
60+
post_meta = []
61+
62+
for p in posts:
63+
try:
64+
velog_post = await velog_client.get_post(str(p["post_uuid"]))
65+
if velog_post and velog_post.body:
66+
full_contents.append(
67+
{
68+
"제목": p["title"],
69+
"내용": velog_post.body,
70+
"조회수": p["latest_view_count"] or 0,
71+
"좋아요 수": p["latest_like_count"] or 0,
72+
}
73+
)
74+
post_meta.append(
75+
{
76+
"title": p["title"],
77+
"username": velog_post.user.username if velog_post.user else "",
78+
"thumbnail": velog_post.thumbnail or "",
79+
"slug": velog_post.url_slug or "",
80+
}
81+
)
82+
except Exception as err:
83+
logger.warning("[user_id=%s] Failed to fetch Velog post : %s", user_id, err)
84+
continue
85+
86+
# 4. LLM 분석
87+
detailed_insight = []
88+
89+
max_len = max(len(full_contents), len(post_meta))
90+
for i in range(max_len):
91+
post = full_contents[i] if i < len(full_contents) else {}
92+
meta = post_meta[i] if i < len(post_meta) else {}
93+
94+
try:
95+
result = analyze_user_posts([post], settings.OPENAI_API_KEY)
96+
result_item = result[0] if result else {}
97+
summary = result_item.get("summary", "") or "[요약 실패]"
98+
key_points = result_item.get("key_points", [])
99+
except Exception as err:
100+
logger.warning(
101+
"[user_id=%s] LLM analysis failed for post index %d: %s", user_id, i, err
102+
)
103+
summary = "[요약 실패]"
104+
key_points = []
105+
106+
detailed_insight.append(
107+
{
108+
"summary": summary,
109+
"key_points": key_points,
110+
"username": meta.get("username", ""),
111+
"thumbnail": meta.get("thumbnail", ""),
112+
"slug": meta.get("slug", ""),
113+
}
114+
)
115+
116+
# 5. 인사이트 저장 포맷
117+
insight = {
118+
"trending_summary": detailed_insight,
119+
"trend_analysis": {"summary": simple_summary},
120+
}
121+
122+
return UserWeeklyTrend(
123+
user_id=user_id,
124+
week_start_date=week_start,
125+
week_end_date=week_end,
126+
insight=insight,
127+
)
128+
129+
except Exception as e:
130+
logger.exception("[user_id=%s] Unexpected error : %s", user_id, e)
131+
return None
132+
133+
134+
async def run_all_users():
135+
logger.info("User weekly trend analysis started")
136+
week_start, week_end = get_previous_week_range()
137+
138+
# 1. 사용자 목록 조회
139+
users = await sync_to_async(list)(
140+
User.objects.filter(email__isnull=False)
141+
.exclude(email="")
142+
.values("id", "username", "access_token", "refresh_token")
143+
)
144+
145+
async with aiohttp.ClientSession() as session:
146+
# 2. VelogClient 싱글톤 생성
147+
velog_client = VelogClient.get_client(
148+
session=session,
149+
access_token="dummy_access_token",
150+
refresh_token="dummy_refresh_token",
151+
)
152+
153+
tasks = []
154+
for user in users:
155+
try:
156+
# 3. 분석 task 등록
157+
tasks.append(
158+
run_weekly_user_trend_analysis(
159+
user, velog_client, week_start, week_end
160+
)
161+
)
162+
except Exception as e:
163+
logger.warning("[user_id=%s] Failed to prepare Velog client : %s", user["id"], e)
164+
165+
# 4. 비동기 병렬 처리
166+
trends = await asyncio.gather(*tasks, return_exceptions=True)
167+
results = []
168+
169+
for i, trend in enumerate(trends):
170+
if isinstance(trend, UserWeeklyTrend):
171+
results.append(trend)
172+
elif isinstance(trend, Exception):
173+
logger.warning("Task %d failed with exception: %s", i, trend)
174+
else:
175+
logger.warning("Task %d returned None (no posts or other issue)", i)
176+
177+
# 5. DB 저장
178+
for trend in results:
179+
try:
180+
await sync_to_async(UserWeeklyTrend.objects.update_or_create)(
181+
user_id=trend.user_id,
182+
week_start_date=trend.week_start_date,
183+
week_end_date=trend.week_end_date,
184+
defaults={"insight": trend.insight},
185+
)
186+
except Exception as e:
187+
logger.exception("[user_id=%s] Failed to save trend : %s", trend.user_id, e)
188+
189+
190+
if __name__ == "__main__":
191+
asyncio.run(run_all_users())
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import logging
2+
import json
3+
from typing import Any
4+
5+
from prompts import SYS_PROM, USER_TREND_PROM, WEEKLY_TREND_PROM
6+
7+
from modules.llm.base_client import LLMClient
8+
from modules.llm.openai.client import OpenAIClient
9+
10+
logger = logging.getLogger("scraping")
11+
12+
13+
def analyze_trending_posts(posts: list, api_key: str) -> dict[Any, Any]:
14+
client: LLMClient = OpenAIClient.get_client(api_key)
15+
prompt = WEEKLY_TREND_PROM.format(posts=posts)
16+
17+
logger.info("Generated weekly trend prompt:\n%s", prompt)
18+
19+
try:
20+
result = client.generate_text(
21+
prompt=prompt,
22+
system_prompt=SYS_PROM,
23+
temperature=0.1,
24+
response_format={"type": "json_object"},
25+
)
26+
27+
if isinstance(result, str):
28+
result = json.loads(result)
29+
30+
return result
31+
except Exception as e:
32+
logger.error("Failed to analyze_trending_posts : %s", e)
33+
raise
34+
35+
36+
def analyze_user_posts(posts: list, api_key: str) -> dict[Any, Any]:
37+
client: LLMClient = OpenAIClient.get_client(api_key)
38+
prompt = USER_TREND_PROM.format(posts=posts)
39+
40+
logger.info("Generated user trend prompt:\n%s", prompt)
41+
42+
try:
43+
result = client.generate_text(
44+
prompt=prompt,
45+
system_prompt=SYS_PROM,
46+
temperature=0.1,
47+
response_format={"type": "json_object"},
48+
)
49+
50+
if isinstance(result, str):
51+
result = json.loads(result)
52+
53+
return result
54+
except Exception as e:
55+
logger.error("Failed to analyze_user_posts : %s", e)
56+
raise

0 commit comments

Comments
 (0)