From 8aa03232e8e4a496ca07c9c2ce5f11f37e386055 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Wed, 25 Mar 2026 19:56:06 +0900 Subject: [PATCH] =?UTF-8?q?Fix:=20[3.0.3]=20JSON=20=ED=8C=8C=EC=8B=B1=20?= =?UTF-8?q?=EA=B0=95=ED=99=94=20+=20Discord=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - summarizer: 코드펜스/잡텍스트 포함된 Claude 응답도 안정적으로 파싱 - summarizer: 프롬프트에 코드펜스 금지 명시 - main: Discord embed용 summary 통합 필드 추가 - docs: n8n Discord 노드를 JSON.stringify() 방식으로 변경 (줄바꿈/따옴표 이스케이프) Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 60 +++++++++++++++++++++++++++-------------------- app/main.py | 10 ++++++++ app/summarizer.py | 41 ++++++++++++++++++-------------- 3 files changed, 68 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 98d106e..a478481 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", "oneliner": "한줄 요약", "main_points": "• 포인트1\n• 포인트2", "conclusion": "결론"} +{"video_url": "...", "title": "...", "channel_name": "...", "status": "ok", "oneliner": "한줄 요약", "main_points": "• 포인트1\n• 포인트2", "conclusion": "결론", "summary": "**한줄 요약**\n\n• 포인트1\n• 포인트2\n\n> 결론"} // 스킵 (라이브/쇼츠) {"video_url": "...", "title": "...", "channel_name": "...", "status": "skipped", "reason": "라이브/예정 영상은 요약 대상이 아닙니다"} @@ -44,6 +44,8 @@ n8n(YouTube API로 새 영상 감지) → `POST /api/news/summarize` → 자막 {"video_url": "...", "title": "...", "channel_name": "...", "status": "error", "error_type": "ValueError", "error_message": "..."} ``` +> `summary`는 `oneliner` + `main_points` + `conclusion`을 합친 Discord embed용 통합 필드. + ## 환경변수 `ANTHROPIC_API_KEY` 필수. `API_SECRET`은 선택(n8n → FastAPI 인증용). @@ -180,20 +182,25 @@ return $input.all().filter(item => { - **Send Body**: ON - **Body Content Type**: JSON - **Specify Body**: Using JSON -- **Body (Expression 모드)**: -```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" +- **Body**: Expression 모드 (`=` 클릭)로 전환 후 아래 입력: + +> **주의**: `"Using JSON"` 모드에서 `{{ $json.summary }}` 같은 문자열 치환을 쓰면 요약 텍스트의 줄바꿈(`\n`)·따옴표(`"`) 때문에 JSON이 깨진다. 반드시 `JSON.stringify()`로 전체 객체를 직렬화해야 한다. + +```javascript +{{ JSON.stringify({ + 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" }, - "footer": { "text": "YouTube 뉴스 요약 봇 • {{ $json.channel_name }}" } + footer: { + text: "YouTube 뉴스 요약 봇 • " + $json.channel_name + } }] -} +}) }} ``` #### 9. Discord 에러 알림 (status=error) @@ -203,20 +210,23 @@ return $input.all().filter(item => { - **Send Body**: ON - **Body Content Type**: JSON - **Specify Body**: Using JSON -- **Body (Expression 모드)**: -```json -{ - "embeds": [{ - "title": "❌ [{{ $json.channel_name }}] 뉴스 요약 실패", - "color": 15548997, - "fields": [ - { "name": "영상 제목", "value": "{{ $json.title }}", "inline": false }, - { "name": "에러 타입", "value": "`{{ $json.error_type }}`", "inline": true }, - { "name": "에러 내용", "value": "```\n{{ $json.error_message }}\n```", "inline": false } +- **Body**: Expression 모드 (`=` 클릭)로 전환 후 아래 입력: + +```javascript +{{ JSON.stringify({ + embeds: [{ + title: "❌ [" + $json.channel_name + "] 뉴스 요약 실패", + color: 15548997, + fields: [ + { name: "영상 제목", value: $json.title, inline: false }, + { name: "에러 타입", value: "`" + $json.error_type + "`", inline: true }, + { name: "에러 내용", value: "```\n" + $json.error_message + "\n```", inline: false } ], - "footer": { "text": "봇 요약 처리 에러 — FastAPI 응답" } + footer: { + text: "봇 요약 처리 에러 — FastAPI 응답" + } }] -} +}) }} ``` ### YouTube Data API 키 발급 diff --git a/app/main.py b/app/main.py index cc7243d..ceca36d 100644 --- a/app/main.py +++ b/app/main.py @@ -41,6 +41,16 @@ async def summarize_video( "error_message": str(e), } + # Discord embed description용 통합 요약 + parts = [] + if summary.get("oneliner"): + parts.append(f"**{summary['oneliner']}**") + if summary.get("main_points"): + parts.append(f"\n{summary['main_points']}") + if summary.get("conclusion"): + parts.append(f"\n> {summary['conclusion']}") + summary["summary"] = "\n".join(parts) + return {**base, "status": "ok", **summary} diff --git a/app/summarizer.py b/app/summarizer.py index bce161c..4be2dd2 100644 --- a/app/summarizer.py +++ b/app/summarizer.py @@ -11,17 +11,12 @@ SYSTEM_PROMPT = """너는 뉴스/경제 유튜브 영상을 시청하고 핵심 영상을 직접 다 본 사람처럼, 구체적인 수치·사례·맥락을 포함해서 요약해줘. 읽는 사람이 영상을 안 봐도 내용을 충분히 파악할 수 있어야 해. -## 출력 형식 (반드시 이 JSON 형식으로만 응답) -```json -{ - "oneliner": "영상의 핵심 메시지를 구체적으로 한 문장", - "main_points": "• 핵심 포인트 1\\n• 핵심 포인트 2\\n• 핵심 포인트 3", - "conclusion": "영상이 전달하려는 메시지와 시청자가 취할 수 있는 액션" -} -``` +## 출력 형식 +반드시 아래 JSON만 출력해. 코드펜스(```)로 감싸지 마. +{"oneliner": "영상의 핵심 메시지를 구체적으로 한 문장", "main_points": "• 핵심 포인트 1\\n• 핵심 포인트 2\\n• 핵심 포인트 3", "conclusion": "영상이 전달하려는 메시지와 시청자가 취할 수 있는 액션"} ## 규칙 -- 반드시 위 JSON 형식으로만 응답. 다른 텍스트 없이 JSON만 출력 +- 반드시 위 JSON 형식으로만 응답. 코드펜스, 설명 등 다른 텍스트 절대 금지 - 한국어로 작성 - main_points는 3~7개 불릿(•)으로 정리. 각 항목에 구체적인 수치, 종목명, 인물, 사건 등을 반드시 포함 - "~에 대해 이야기했다" 같은 메타 서술 금지. 내용 자체를 직접 전달 @@ -44,18 +39,28 @@ def summarize(transcript: str, title: str) -> dict: ) 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": ""} + parsed = _parse_json_response(raw) return { "oneliner": parsed.get("oneliner", ""), "main_points": parsed.get("main_points", ""), "conclusion": parsed.get("conclusion", ""), } + + +def _parse_json_response(raw: str) -> dict: + """Claude 응답에서 JSON을 추출하여 파싱한다.""" + # 1) 코드펜스 제거 (```json ... ``` 또는 ``` ... ```) + json_match = re.search(r"```(?:json)?\s*(.*?)\s*```", raw, re.DOTALL) + json_str = json_match.group(1) if json_match else raw.strip() + + # 2) 첫 번째 { ~ 마지막 } 만 추출 (앞뒤 잡텍스트 제거) + brace_match = re.search(r"\{.*\}", json_str, re.DOTALL) + if brace_match: + json_str = brace_match.group(0) + + try: + return json.loads(json_str) + except json.JSONDecodeError: + # 파싱 실패 시 전체 텍스트를 oneliner로 + return {"oneliner": raw, "main_points": "", "conclusion": ""}