From 9c2bf7c1ce780e05affdcdcba3bb1a6ce87a8813 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Wed, 25 Mar 2026 17:18:31 +0900 Subject: [PATCH] =?UTF-8?q?Fix:=20[3.0.2]=20=EC=9A=94=EC=95=BD=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EA=B5=AC=EC=A1=B0=ED=99=94=20+=20Discord=20?= =?UTF-8?q?=EC=9E=84=EB=B2=A0=EB=93=9C=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 요약을 JSON으로 구조화: oneliner, main_points, conclusion 분리 - Claude에게 JSON 형식으로만 응답하도록 프롬프트 변경 - n8n Discord 임베드: 섹션별 필드 분리, 이모지, 타임스탬프 추가 - JSON.stringify Expression으로 특수문자 이스케이프 처리 - 전체 문서 API 응답 형식 업데이트 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- README.md | 2 +- app/main.py | 2 +- app/summarizer.py | 37 +++++++++++++++++---- docs/development.md | 2 +- docs/n8n-setup.md | 81 ++++++++++++++++++++++++++++----------------- 6 files changed, 86 insertions(+), 40 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f0de8f5..98d106e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ n8n(YouTube API로 새 영상 감지) → `POST /api/news/summarize` → 자막 ```json // 성공 -{"video_url": "...", "title": "...", "channel_name": "...", "status": "ok", "summary": "요약 텍스트"} +{"video_url": "...", "title": "...", "channel_name": "...", "status": "ok", "oneliner": "한줄 요약", "main_points": "• 포인트1\n• 포인트2", "conclusion": "결론"} // 스킵 (라이브/쇼츠) {"video_url": "...", "title": "...", "channel_name": "...", "status": "skipped", "reason": "라이브/예정 영상은 요약 대상이 아닙니다"} diff --git a/README.md b/README.md index 8904e83..b160682 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ OCI 등 클라우드 서버에서는 YouTube가 데이터센터 IP를 봇으로 ```json // 성공 -{"video_url": "...", "title": "...", "channel_name": "...", "status": "ok", "summary": "요약 텍스트"} +{"video_url": "...", "title": "...", "channel_name": "...", "status": "ok", "oneliner": "한줄 요약", "main_points": "• 포인트1\n• 포인트2", "conclusion": "결론"} // 스킵 (라이브/쇼츠) {"video_url": "...", "title": "...", "channel_name": "...", "status": "skipped", "reason": "라이브/예정 영상은 요약 대상이 아닙니다"} diff --git a/app/main.py b/app/main.py index f0649c6..cc7243d 100644 --- a/app/main.py +++ b/app/main.py @@ -41,7 +41,7 @@ async def summarize_video( "error_message": str(e), } - return {**base, "status": "ok", "summary": summary} + return {**base, "status": "ok", **summary} @app.get("/health") diff --git a/app/summarizer.py b/app/summarizer.py index 42fe3ec..bce161c 100644 --- a/app/summarizer.py +++ b/app/summarizer.py @@ -1,3 +1,6 @@ +import json +import re + import anthropic from app.config import settings @@ -8,20 +11,26 @@ SYSTEM_PROMPT = """너는 뉴스/경제 유튜브 영상을 시청하고 핵심 영상을 직접 다 본 사람처럼, 구체적인 수치·사례·맥락을 포함해서 요약해줘. 읽는 사람이 영상을 안 봐도 내용을 충분히 파악할 수 있어야 해. -## 형식 -- **한줄 요약**: 영상의 핵심 메시지를 구체적으로 한 문장 -- **주요 내용**: 영상에서 다룬 핵심 포인트를 3~7개 불릿으로 정리. 각 항목에 구체적인 수치, 종목명, 인물, 사건 등을 반드시 포함 -- **결론/시사점**: 영상이 전달하려는 메시지와 시청자가 취할 수 있는 액션 +## 출력 형식 (반드시 이 JSON 형식으로만 응답) +```json +{ + "oneliner": "영상의 핵심 메시지를 구체적으로 한 문장", + "main_points": "• 핵심 포인트 1\\n• 핵심 포인트 2\\n• 핵심 포인트 3", + "conclusion": "영상이 전달하려는 메시지와 시청자가 취할 수 있는 액션" +} +``` ## 규칙 +- 반드시 위 JSON 형식으로만 응답. 다른 텍스트 없이 JSON만 출력 - 한국어로 작성 +- main_points는 3~7개 불릿(•)으로 정리. 각 항목에 구체적인 수치, 종목명, 인물, 사건 등을 반드시 포함 - "~에 대해 이야기했다" 같은 메타 서술 금지. 내용 자체를 직접 전달 - 자막의 오타나 말더듬은 무시하고 의미 중심으로 정리 - 영상에서 언급된 구체적 수치(%, 금액, 날짜 등)가 있으면 반드시 포함 """ -def summarize(transcript: str, title: str) -> str: +def summarize(transcript: str, title: str) -> dict: message = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=2048, @@ -33,4 +42,20 @@ def summarize(transcript: str, title: str) -> str: } ], ) - return message.content[0].text + raw = message.content[0].text + + # JSON 블록 추출 (```json ... ``` 또는 순수 JSON) + json_match = re.search(r"```json\s*(.*?)\s*```", raw, re.DOTALL) + json_str = json_match.group(1) if json_match else raw.strip() + + try: + parsed = json.loads(json_str) + except json.JSONDecodeError: + # 파싱 실패 시 전체 텍스트를 oneliner로 + parsed = {"oneliner": raw, "main_points": "", "conclusion": ""} + + return { + "oneliner": parsed.get("oneliner", ""), + "main_points": parsed.get("main_points", ""), + "conclusion": parsed.get("conclusion", ""), + } diff --git a/docs/development.md b/docs/development.md index 7f47476..de8857c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -52,7 +52,7 @@ YouTube 영상 URL을 받아 자막을 추출하고 Claude로 요약합니다. ```json // 성공 -{"video_url": "...", "title": "...", "channel_name": "...", "status": "ok", "summary": "요약 텍스트"} +{"video_url": "...", "title": "...", "channel_name": "...", "status": "ok", "oneliner": "한줄 요약", "main_points": "• 포인트1\n• 포인트2", "conclusion": "결론"} // 스킵 (라이브/쇼츠) {"video_url": "...", "title": "...", "channel_name": "...", "status": "skipped", "reason": "..."} diff --git a/docs/n8n-setup.md b/docs/n8n-setup.md index 719d527..2d0bb29 100644 --- a/docs/n8n-setup.md +++ b/docs/n8n-setup.md @@ -193,23 +193,43 @@ return $input.all().filter(item => { | Body Content Type | JSON | | Specify Body | Using JSON | -**Body (Expression 모드):** +**Body:** -```json -{ - "embeds": [{ - "title": "📰 [{{ $json.channel_name }}] {{ $json.title }}", - "url": "{{ $json.video_url }}", - "description": "{{ $json.summary }}", - "color": 2829105, - "thumbnail": { - "url": "https://img.youtube.com/vi/{{ $json.video_url.split('v=')[1] }}/hqdefault.jpg" +Specify Body를 **Using JSON**으로 선택 후, JSON 입력란 우측의 **Expression** 토글을 켜고 아래 코드를 입력합니다. `JSON.stringify`를 사용해야 요약 텍스트의 특수문자(따옴표, 줄바꿈 등)가 자동 이스케이프됩니다. + +```javascript +={{ JSON.stringify({ + embeds: [{ + title: "📰 [" + $json.channel_name + "] " + $json.title, + url: $json.video_url, + description: "### 💡 " + $json.oneliner, + color: 2829105, + fields: [ + { + name: "📋 주요 내용", + value: $json.main_points, + inline: false + }, + { + name: "🎯 결론 / 시사점", + value: $json.conclusion, + inline: false + }, + { + name: "🔗 원본 영상", + value: $json.video_url, + inline: false + } + ], + thumbnail: { + url: "https://img.youtube.com/vi/" + $json.video_url.split("v=")[1] + "/hqdefault.jpg" }, - "footer": { - "text": "YouTube 뉴스 요약 봇 • {{ $json.channel_name }}" - } + footer: { + text: "YouTube 뉴스 요약 봇 • " + $json.channel_name + }, + timestamp: new Date().toISOString() }] -} +}) }} ``` ### 9. Discord 에러 알림 (status=error) @@ -221,26 +241,27 @@ return $input.all().filter(item => { | URL | Discord 웹훅 URL | | Send Body | ON | | Body Content Type | JSON | -| Specify Body | Using JSON | +| Specify Body | Using JSON (Expression 토글 ON) | -**Body (Expression 모드):** +**Body:** -```json -{ - "embeds": [{ - "title": "❌ [{{ $json.channel_name }}] 뉴스 요약 실패", - "color": 15548997, - "fields": [ - { "name": "영상 제목", "value": "{{ $json.title }}", "inline": false }, - { "name": "영상 URL", "value": "{{ $json.video_url }}", "inline": false }, - { "name": "에러 타입", "value": "`{{ $json.error_type }}`", "inline": true }, - { "name": "에러 내용", "value": "```\n{{ $json.error_message }}\n```", "inline": false } +```javascript +={{ JSON.stringify({ + embeds: [{ + title: "❌ [" + $json.channel_name + "] 뉴스 요약 실패", + color: 15548997, + fields: [ + { name: "영상 제목", value: $json.title || "(제목 없음)", inline: false }, + { name: "영상 URL", value: $json.video_url, inline: false }, + { name: "에러 타입", value: "`" + $json.error_type + "`", inline: true }, + { name: "에러 내용", value: "```\\n" + $json.error_message + "\\n```", inline: false } ], - "footer": { - "text": "봇 요약 처리 에러 — FastAPI 응답" - } + footer: { + text: "봇 요약 처리 에러 — FastAPI 응답" + }, + timestamp: new Date().toISOString() }] -} +}) }} ``` ## 에러 처리