import os
import streamlit as st
import json
import anthropic
import requests
import logging
from gradio_client import Client
import markdown
import tempfile
import base64
from datetime import datetime
import re
from bs4 import BeautifulSoup # BeautifulSoup 추가
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s')
# API 설정
api_key = os.environ.get("API_KEY")
client = anthropic.Anthropic(api_key=api_key)
# 이미지 생성 API URL
IMAGE_API_URL = "http://211.233.58.201:7896"
# 최대 토큰 수 설정 (Claude-3 Sonnet의 최대 토큰 수)
MAX_TOKENS = 7999
# SerpHouse API Key 설정
SERPHOUSE_API_KEY = os.environ.get("SERPHOUSE_API_KEY", "")
def get_system_prompt():
return """
당신은 전문 블로그 작성 전문가입니다. 모든 블로그 글 작성 요청에 대해 다음의 8단계 프레임워크를 철저히 따르되, 자연스럽고 매력적인 글이 되도록 작성해야 합니다:
독자 연결 단계 1.1. 공감대 형성을 위한 친근한 인사 1.2. 독자의 실제 고민을 반영한 도입 질문 1.3. 주제에 대한 즉각적 관심 유도
문제 정의 단계 2.1. 독자의 페인포인트 구체화 2.2. 문제의 시급성과 영향도 분석 2.3. 해결 필요성에 대한 공감대 형성
전문성 입증 단계 3.1. 객관적 데이터 기반 분석 3.2. 전문가 견해와 연구 결과 인용 3.3. 실제 사례를 통한 문제 구체화
솔루션 제공 단계 4.1. 단계별 실천 가이드라인 제시 4.2. 즉시 적용 가능한 구체적 팁 4.3. 예상 장애물과 극복 방안 포함
신뢰도 강화 단계 5.1. 실제 성공 사례 제시 5.2. 구체적 사용자 후기 인용 5.3. 객관적 데이터로 효과 입증
행동 유도 단계 6.1. 명확한 첫 실천 단계 제시 6.2. 시급성을 강조한 행동 촉구 6.3. 실천 동기 부여 요소 포함
진정성 강화 단계 7.1. 솔루션의 한계 투명하게 공개 7.2. 개인별 차이 존재 인정 7.3. 필요 조건과 주의사항 명시
관계 지속 단계 8.1. 진정성 있는 감사 인사 8.2. 다음 컨텐츠 예고로 기대감 조성 8.3. 소통 채널 안내
작성 시 준수사항 9.1. 글자 수: 1500-2000자 내외 9.2. 문단 길이: 3-4문장 이내 9.3. 시각적 구분: 소제목, 구분선, 번호 목록 활용 9.4. 톤앤매너: 친근하고 전문적인 대화체 9.5. 데이터: 모든 정보의 출처 명시 9.6. 가독성: 명확한 단락 구분과 강조점 사용
이러한 프레임워크를 바탕으로, 요청받은 주제에 대해 체계적이고 매력적인 블로그 포스트를 작성하겠습니다.
"""
def test_image_api_connection():
"""이미지 API 서버 연결 테스트"""
try:
client = Client(IMAGE_API_URL)
return "이미지 API 연결 성공: 정상 작동 중"
except Exception as e:
logging.error(f"이미지 API 연결 테스트 실패: {e}")
return f"이미지 API 연결 실패: {e}"
def generate_image(prompt, width=768, height=768, guidance=3.5, inference_steps=30, seed=3):
"""이미지 생성 함수"""
if not prompt:
return None, "오류: 프롬프트를 입력해주세요"
try:
client = Client(IMAGE_API_URL)
result = client.predict(
prompt=prompt,
width=int(width),
height=int(height),
guidance=float(guidance),
inference_steps=int(inference_steps),
seed=int(seed),
do_img2img=False,
init_image=None,
image2image_strength=0.8,
resize_img=True,
api_name="/generate_image"
)
logging.info(f"이미지 생성 성공: {result[1]}")
return result[0], f"사용된 시드: {result[1]}"
except Exception as e:
logging.error(f"이미지 생성 실패: {str(e)}")
return None, f"오류: {str(e)}"
def extract_image_prompt(blog_content, blog_topic):
"""블로그 내용에서 이미지 생성을 위한 프롬프트 추출"""
image_prompt_system = f"""
다음은 '{blog_topic}'에 관한 블로그 글입니다. 이 블로그 글의 내용을 기반으로 적절한 이미지를 생성하기 위한
프롬프트를 작성해주세요. 프롬프트는 영어로 작성하고, 구체적인 시각적 요소를 담아야 합니다.
프롬프트만 반환하세요(다른 설명 없이).
예시 형식:
"A professional photo of [subject], [specific details], [atmosphere], [lighting], [perspective], high quality, detailed"
"""
try:
response = client.messages.create(
model="claude-3-7-sonnet-20250219",
max_tokens=150,
system=image_prompt_system,
messages=[{"role": "user", "content": blog_content}]
)
# 응답에서 프롬프트 추출
image_prompt = response.content[0].text.strip()
logging.info(f"생성된 이미지 프롬프트: {image_prompt}")
return image_prompt
except Exception as e:
logging.error(f"이미지 프롬프트 생성 오류: {e}")
return f"A professional photo related to {blog_topic}, detailed, high quality"
# 마크다운을 HTML로 변환하는 함수
def convert_md_to_html(md_text, title="Ginigen Blog"):
html_content = markdown.markdown(md_text)
html_doc = f"""
{title}
{html_content}
"""
return html_doc
# 웹 검색 키워드 추출 함수
def extract_keywords(text: str, top_k: int = 5) -> str:
"""
1) 한글(가-힣), 영어(a-zA-Z), 숫자(0-9), 공백만 남김
2) 공백 기준 토큰 분리
3) 최대 top_k개만
"""
text = re.sub(r"[^a-zA-Z0-9가-힣\s]", "", text)
tokens = text.split()
key_tokens = tokens[:top_k]
return " ".join(key_tokens)
# Mock 검색 결과 생성 함수
def generate_mock_search_results(query):
"""API 연결이 안될 때 사용할 가상 검색 결과 생성"""
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
mock_results = [
{
"title": f"{query}에 관한 최신 정보",
"link": "https://example.com/article1",
"snippet": f"{query}에 관한 가상 검색 결과입니다. 이 결과는 API 연결 문제로 인해 생성된 가상 데이터입니다. 실제 검색 결과가 아님을 참고하세요. 생성 시간: {current_time}",
"displayed_link": "example.com/article1"
},
{
"title": f"{query} 관련 연구 동향",
"link": "https://example.org/research",
"snippet": "이것은 API 연결 문제로 인한 가상 검색 결과입니다. 실제 검색 결과를 보여드리지 못해 죄송합니다. 대신 AI의 기존 지식을 활용하여 답변드리겠습니다.",
"displayed_link": "example.org/research"
},
{
"title": f"{query}의 역사적 배경",
"link": "https://example.net/history",
"snippet": "이 가상 검색 결과는 API 연결 문제로 인해 생성되었습니다. 참고용으로만 사용해주세요.",
"displayed_link": "example.net/history"
}
]
summary_lines = []
for idx, item in enumerate(mock_results, start=1):
title = item.get("title", "No title")
link = item.get("link", "#")
snippet = item.get("snippet", "No description")
displayed_link = item.get("displayed_link", link)
summary_lines.append(
f"### Result {idx}: {title}\n\n"
f"{snippet}\n\n"
f"**출처**: [{displayed_link}]({link})\n\n"
f"---\n"
)
notice = """
# 가상 검색 결과 (API 연결 문제로 인해 생성됨)
아래는 API 연결 문제로 인해 생성된 가상 검색 결과입니다. 실제 검색 결과가 아님을 참고하세요.
대신 AI의 기존 지식을 활용하여 최대한 정확한 답변을 드리겠습니다.
"""
return notice + "\n".join(summary_lines)
# Google 검색 함수 (SerpAPI 대신 직접 검색)
# Google 검색 함수 (BeautifulSoup을 사용하여 결과 파싱)
def do_google_search(query, num_results=5):
try:
# 다양한 User-Agent 사용 (Google 차단 방지)
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
'Accept-Encoding': 'gzip, deflate, br',
'Referer': 'https://www.google.com/',
'DNT': '1',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Cache-Control': 'max-age=0',
}
# 검색 URL (일부 파라미터 추가)
search_url = f"https://www.google.com/search?q={query}&num={num_results}&hl=ko&gl=kr"
logging.info(f"구글 검색 URL: {search_url}")
# 요청 보내기 (짧은 타임아웃 설정)
response = requests.get(search_url, headers=headers, timeout=10)
# 응답이 성공적인지 확인
if response.status_code != 200:
logging.error(f"Google 검색 응답 상태 코드: {response.status_code}")
return generate_mock_search_results(query)
# BeautifulSoup으로 HTML 파싱
soup = BeautifulSoup(response.text, 'html.parser')
# 검색 결과 추출
organic_results = []
# 검색 결과 컨테이너 찾기 (Google의 HTML 구조에 따라 변경될 수 있음)
result_containers = soup.select('div.g')
if not result_containers:
logging.warning("Google 검색 결과 컨테이너를 찾을 수 없습니다. 대체 선택자를 시도합니다.")
# 대체 선택자 시도
result_containers = soup.select('div[data-hveid]')
counter = 0
for container in result_containers:
if counter >= num_results:
break
# 제목 추출
title_element = container.select_one('h3')
if not title_element:
continue
title = title_element.get_text()
# 링크 추출
link_element = container.select_one('a')
if not link_element:
continue
link = link_element.get('href', '')
if link.startswith('/url?'):
# Google의 리다이렉트 URL에서 실제 URL 추출
link = link.split('q=')[1].split('&')[0] if 'q=' in link else link
elif not link.startswith('http'):
continue
# 스니펫 추출
snippet_element = container.select_one('div.VwiC3b') or container.select_one('span.aCOpRe')
snippet = snippet_element.get_text() if snippet_element else "설명 없음"
# 표시 링크 추출
displayed_link_element = container.select_one('cite')
displayed_link = displayed_link_element.get_text() if displayed_link_element else link
organic_results.append({
"title": title,
"link": link,
"snippet": snippet,
"displayed_link": displayed_link
})
counter += 1
if not organic_results:
logging.warning("검색 결과를 파싱할 수 없습니다. 선택자가 변경되었을 수 있습니다.")
return generate_mock_search_results(query)
# 검색 결과 마크다운 형식으로 변환
summary_lines = []
for idx, item in enumerate(organic_results, start=1):
title = item.get("title", "No title")
link = item.get("link", "#")
snippet = item.get("snippet", "No description")
displayed_link = item.get("displayed_link", link)
summary_lines.append(
f"### Result {idx}: {title}\n\n"
f"{snippet}\n\n"
f"**출처**: [{displayed_link}]({link})\n\n"
f"---\n"
)
# 모델에게 명확한 지침 추가
instructions = """
# 웹 검색 결과
아래는 검색 결과입니다. 질문에 답변할 때 이 정보를 활용하세요:
1. 각 결과의 제목, 내용, 출처 링크를 참고하세요
2. 답변에 관련 정보의 출처를 명시적으로 인용하세요 (예: "X 출처에 따르면...")
3. 응답에 실제 출처 링크를 포함하세요
4. 여러 출처의 정보를 종합하여 답변하세요
"""
search_results = instructions + "\n".join(summary_lines)
logging.info(f"Google 검색 결과 {len(organic_results)}개 파싱 완료")
return search_results
except Exception as e:
logging.error(f"Google 검색 실패: {e}")
return generate_mock_search_results(query)
# 웹 검색 함수
def do_web_search(query: str) -> str:
"""
웹 검색을 수행하는 함수 - SerpHouse API 또는 직접 구글 검색
"""
try:
# API 키가 없거나 'mock'인 경우
if not SERPHOUSE_API_KEY or "mock" in SERPHOUSE_API_KEY.lower():
logging.warning("API 키가 없거나 Mock 모드입니다. 가상 검색 결과를 반환합니다.")
return generate_mock_search_results(query)
# SerpHouse API 사용
url = "https://api.serphouse.com/serp/live"
params = {
"q": query,
"domain": "google.com",
"serp_type": "web",
"device": "desktop",
"lang": "ko", # 한국어 결과
"num": "5" # 결과 수 줄임
}
headers = {
"Authorization": f"Bearer {SERPHOUSE_API_KEY}"
}
logging.info(f"SerpHouse API 호출 중... 검색어: {query}")
# 짧은 타임아웃으로 요청 시도
response = requests.get(url, headers=headers, params=params, timeout=15)
response.raise_for_status()
logging.info(f"SerpHouse API 응답 상태 코드: {response.status_code}")
data = response.json()
# 다양한 응답 구조 처리
results = data.get("results", {})
organic = None
# 가능한 응답 구조 1
if isinstance(results, dict) and "organic" in results:
organic = results["organic"]
# 가능한 응답 구조 2
elif isinstance(results, dict) and "results" in results:
if isinstance(results["results"], dict) and "organic" in results["results"]:
organic = results["results"]["organic"]
# 가능한 응답 구조 3
elif "organic" in data:
organic = data["organic"]
if not organic:
logging.warning("응답에서 organic 결과를 찾을 수 없습니다. 구글 직접 검색으로 전환합니다.")
return do_google_search(query)
# 결과 수 제한 및 컨텍스트 길이 최적화
max_results = min(5, len(organic))
limited_organic = organic[:max_results]
# 결과 형식 개선
summary_lines = []
for idx, item in enumerate(limited_organic, start=1):
title = item.get("title", "No title")
link = item.get("link", "#")
snippet = item.get("snippet", "No description")
displayed_link = item.get("displayed_link", link)
summary_lines.append(
f"### Result {idx}: {title}\n\n"
f"{snippet}\n\n"
f"**출처**: [{displayed_link}]({link})\n\n"
f"---\n"
)
# 모델에게 명확한 지침 추가
instructions = """
# 웹 검색 결과
아래는 검색 결과입니다. 질문에 답변할 때 이 정보를 활용하세요:
1. 각 결과의 제목, 내용, 출처 링크를 참고하세요
2. 답변에 관련 정보의 출처를 명시적으로 인용하세요 (예: "X 출처에 따르면...")
3. 응답에 실제 출처 링크를 포함하세요
4. 여러 출처의 정보를 종합하여 답변하세요
"""
search_results = instructions + "\n".join(summary_lines)
logging.info(f"검색 결과 {len(limited_organic)}개 처리 완료")
return search_results
except requests.exceptions.Timeout:
logging.error("Web search timed out, 직접 구글 검색으로 전환합니다.")
return do_google_search(query)
except Exception as e:
logging.error(f"Web search failed: {e}, 직접 구글 검색으로 전환합니다.")
return do_google_search(query)
def chatbot_interface():
st.title("Ginigen Blog")
# 모델 고정 설정
if "ai_model" not in st.session_state:
st.session_state["ai_model"] = "claude-3-7-sonnet-20250219"
# 세션 상태 초기화
if "messages" not in st.session_state:
st.session_state.messages = []
# 자동 저장 기능
if "auto_save" not in st.session_state:
st.session_state.auto_save = True
# 이미지 생성 토글
if "generate_image" not in st.session_state:
st.session_state.generate_image = False
# 웹 검색 토글
if "use_web_search" not in st.session_state:
st.session_state.use_web_search = False
# 이미지 API 상태
if "image_api_status" not in st.session_state:
st.session_state.image_api_status = test_image_api_connection()
# 대화 기록 관리 (사이드바)
st.sidebar.title("대화 기록 관리")
# 자동 저장 토글
st.session_state.auto_save = st.sidebar.toggle("자동 저장", value=st.session_state.auto_save)
# 이미지 생성 토글
st.session_state.generate_image = st.sidebar.toggle("블로그 글 작성 후 이미지 자동 생성", value=st.session_state.generate_image)
# 웹 검색 토글
st.session_state.use_web_search = st.sidebar.toggle("주제 웹 검색 및 분석", value=st.session_state.use_web_search)
# 이미지 API 상태 표시
st.sidebar.text(st.session_state.image_api_status)
# 이미지 생성 설정 (토글이 켜져 있을 때만 표시)
if st.session_state.generate_image:
st.sidebar.subheader("이미지 생성 설정")
width = st.sidebar.slider("너비", 256, 1024, 768, 64)
height = st.sidebar.slider("높이", 256, 1024, 768, 64)
guidance = st.sidebar.slider("가이던스 스케일", 1.0, 20.0, 3.5, 0.1)
inference_steps = st.sidebar.slider("인퍼런스 스텝", 1, 50, 30, 1)
seed = st.sidebar.number_input("시드", value=3, min_value=0, step=1)
else:
# 기본값 설정
width, height, guidance, inference_steps, seed = 768, 768, 3.5, 30, 3
# 블로그 내용 다운로드 섹션
st.sidebar.title("블로그 다운로드")
# 최신 블로그 내용 가져오기
latest_blog = None
latest_blog_title = "블로그 글"
if len(st.session_state.messages) > 0:
# 가장 최근 assistant 메시지 찾기
for msg in reversed(st.session_state.messages):
if msg["role"] == "assistant" and msg["content"].strip():
latest_blog = msg["content"]
# 타이틀 추출 시도 (첫 번째 제목 태그 사용)
title_match = re.search(r'# (.*?)(\n|$)', latest_blog)
if title_match:
latest_blog_title = title_match.group(1).strip()
# 사용자 입력을 타이틀로 사용
elif len(st.session_state.messages) >= 2:
for i in range(len(st.session_state.messages)-1, -1, -1):
if st.session_state.messages[i]["role"] == "user":
latest_blog_title = st.session_state.messages[i]["content"][:30].strip()
if len(st.session_state.messages[i]["content"]) > 30:
latest_blog_title += "..."
break
break
# 다운로드 버튼 그룹
if latest_blog:
st.sidebar.subheader("최근 블로그 다운로드")
col1, col2 = st.sidebar.columns(2)
# 마크다운으로 다운로드
with col1:
st.download_button(
label="마크다운",
data=latest_blog,
file_name=f"{latest_blog_title}.md",
mime="text/markdown"
)
# HTML로 다운로드
with col2:
html_content = convert_md_to_html(latest_blog, latest_blog_title)
st.download_button(
label="HTML",
data=html_content,
file_name=f"{latest_blog_title}.html",
mime="text/html"
)
# 대화 기록 불러오기
uploaded_file = st.sidebar.file_uploader("대화 기록 불러오기", type=['json'])
if uploaded_file is not None:
try:
content = uploaded_file.getvalue().decode()
if content.strip():
st.session_state.messages = json.loads(content)
st.sidebar.success("대화 기록을 성공적으로 불러왔습니다!")
else:
st.sidebar.warning("업로드된 파일이 비어 있습니다.")
except json.JSONDecodeError:
st.sidebar.error("올바른 JSON 형식의 파일이 아닙니다.")
except Exception as e:
st.sidebar.error(f"파일 처리 중 오류가 발생했습니다: {str(e)}")
# 대화 기록 초기화 버튼
if st.sidebar.button("대화 기록 초기화"):
st.session_state.messages = []
st.sidebar.success("대화 기록이 초기화되었습니다.")
# 메시지 표시
for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.markdown(message["content"])
# 이미지가 있는 경우 표시
if "image" in message:
st.image(message["image"], caption=message.get("image_caption", "생성된 이미지"))
# 사용자 입력
if prompt := st.chat_input("무엇을 도와드릴까요?"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
# AI 응답 생성
with st.chat_message("assistant"):
message_placeholder = st.empty()
full_response = ""
# 웹 검색 수행 (웹 검색 옵션이 켜져 있을 경우)
system_prompt = get_system_prompt()
if st.session_state.use_web_search:
with st.spinner("웹에서 관련 정보를 검색 중..."):
try:
search_query = extract_keywords(prompt, top_k=5)
st.info(f"검색어: {search_query}")
# 두 가지 방법 모두 시도 (SerpHouse API와 직접 검색)
search_results = do_web_search(search_query)
if "가상 검색 결과" in search_results:
st.warning("실제 검색 결과를 가져올 수 없어 기존 지식을 활용합니다.")
else:
st.success(f"검색 완료: '{search_query}'에 대한 정보를 수집했습니다.")
# 시스템 프롬프트에 검색 결과 추가
system_prompt += f"\n\n검색 결과:\n{search_results}\n"
except Exception as e:
st.error(f"웹 검색 중 오류가 발생했습니다: {str(e)}")
logging.error(f"웹 검색 오류: {str(e)}")
system_prompt += "\n\n웹 검색이 실패했습니다. 기존 지식을 바탕으로 답변하세요."
# API 호출
with client.messages.stream(
max_tokens=MAX_TOKENS,
system=system_prompt,
messages=[{"role": m["role"], "content": m["content"]} for m in st.session_state.messages],
model=st.session_state["ai_model"]
) as stream:
for text in stream.text_stream:
full_response += str(text) if text is not None else ""
message_placeholder.markdown(full_response + "▌")
message_placeholder.markdown(full_response)
# 이미지 생성 옵션이 켜져 있는 경우
if st.session_state.generate_image:
with st.spinner("블로그에 맞는 이미지 생성 중..."):
# 이미지 프롬프트 생성
image_prompt = extract_image_prompt(full_response, prompt)
# 이미지 생성
image, image_caption = generate_image(
image_prompt,
width=width,
height=height,
guidance=guidance,
inference_steps=inference_steps,
seed=seed
)
if image:
st.image(image, caption=image_caption)
# 이미지 정보를 응답에 포함
st.session_state.messages.append({
"role": "assistant",
"content": full_response,
"image": image,
"image_caption": image_caption
})
else:
st.error(f"이미지 생성 실패: {image_caption}")
st.session_state.messages.append({
"role": "assistant",
"content": full_response
})
else:
# 이미지 생성 없이 응답만 저장
st.session_state.messages.append({
"role": "assistant",
"content": full_response
})
# 블로그 다운로드 버튼 표시 (응답 바로 아래에)
st.subheader("이 블로그 다운로드:")
col1, col2 = st.columns(2)
with col1:
st.download_button(
label="마크다운으로 저장",
data=full_response,
file_name=f"{prompt[:30]}.md",
mime="text/markdown"
)
with col2:
html_content = convert_md_to_html(full_response, prompt[:30])
st.download_button(
label="HTML로 저장",
data=html_content,
file_name=f"{prompt[:30]}.html",
mime="text/html"
)
# 자동 저장 기능
if st.session_state.auto_save:
try:
# 이미지 정보는 저장하지 않음 (JSON에는 바이너리 데이터를 직접 저장할 수 없음)
save_messages = []
for msg in st.session_state.messages:
save_msg = {"role": msg["role"], "content": msg["content"]}
save_messages.append(save_msg)
# 현재 시간을 포함한 파일명 생성
current_time = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f'chat_history_auto_save_{current_time}.json'
with open(filename, 'w', encoding='utf-8') as f:
json.dump(save_messages, f, ensure_ascii=False, indent=4)
except Exception as e:
st.sidebar.error(f"자동 저장 중 오류 발생: {str(e)}")
# 대화 기록 다운로드
if st.sidebar.button("대화 기록 다운로드"):
# 이미지 정보는 저장하지 않음
save_messages = []
for msg in st.session_state.messages:
save_msg = {"role": msg["role"], "content": msg["content"]}
save_messages.append(save_msg)
json_history = json.dumps(save_messages, indent=4, ensure_ascii=False)
st.sidebar.download_button(
label="대화 기록 저장하기",
data=json_history,
file_name="chat_history.json",
mime="application/json"
)
def main():
chatbot_interface()
if __name__ == "__main__":
# requirements.txt 파일 생성
with open("requirements.txt", "w") as f:
f.write("streamlit>=1.31.0\n")
f.write("anthropic>=0.18.1\n")
f.write("gradio-client>=1.8.0\n")
f.write("requests>=2.32.3\n")
f.write("markdown>=3.5.1\n")
f.write("pillow>=10.1.0\n")
main()