""" Ginigen Blog / Streamlit App — Brave Search API Edition ──────────────────────────────────────────────────────────────────────── * 2025-04-23 : SerpHouse 의존성 ⇢ Brave Search API 로 전면 교체 * 환경변수 SERPHOUSE_API_KEY → Brave API Key 그대로 사용 * **원본 코드의 기능 100 % 유지** - Markdown / HTML 블로그 다운로드 (사이드바 + 본문) - 대화 기록 JSON 업로드 & 다운로드 + 백그라운드 자동 저장 - 이미지 자동 생성 옵션 - Streamlit 모든 UI 토글 ──────────────────────────────────────────────────────────────────────── """ # ──────────────────────────────── Imports ──────────────────────────────── import os, json, re, logging, requests, markdown from datetime import datetime import streamlit as st import anthropic from gradio_client import Client # from bs4 import BeautifulSoup # 필요 시 주석 해제 # ──────────────────────────────── 환경 변수 / 상수 ─────────────────────────── ANTHROPIC_KEY = os.getenv("API_KEY", "") BRAVE_KEY = os.getenv("SERPHOUSE_API_KEY", "") # 이름 유지 BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search" IMAGE_API_URL = "http://211.233.58.201:7896" MAX_TOKENS = 7_999 # ──────────────────────────────── 로깅 ────────────────────────────────────── logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") # ──────────────────────────────── Anthropic Client ───────────────────────── client = anthropic.Anthropic(api_key=ANTHROPIC_KEY) # ──────────────────────────────── 블로그 작성 시스템 프롬프트 ──────────────── def get_system_prompt() -> str: 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. 가독성: 명확한 단락 구분과 강조점 사용 """ # ──────────────────────────────── Brave Search API ───────────────────────── def brave_search(query: str, count: int = 5): """ Brave Web Search API 호출 → list[dict] 반환 필드: index, title, link, snippet, displayed_link """ if not BRAVE_KEY: raise RuntimeError("⚠️ SERPHOUSE_API_KEY (Brave API Key) 환경변수가 비어 있습니다.") headers = { "Accept": "application/json", "Accept-Encoding": "gzip", "X-Subscription-Token": BRAVE_KEY } params = {"q": query, "count": str(count)} r = requests.get(BRAVE_ENDPOINT, headers=headers, params=params, timeout=15) r.raise_for_status() data = r.json() raw = data.get("web", {}).get("results") or data.get("results", []) arts = [] for i, res in enumerate(raw[:count], 1): url = res.get("url", res.get("link", "")) host = re.sub(r"https?://(www\.)?", "", url).split("/")[0] arts.append({ "index": i, "title": res.get("title", "제목 없음"), "link": url, "snippet": res.get("description", res.get("text", "내용 없음")), "displayed_link": host }) return arts def mock_results(query: str) -> str: ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") return (f"# 가상 검색 결과 (생성: {ts})\n\n" f"### Result 1: {query} 관련 예시 결과\n\n" "API 호출 실패로 생성된 임시 데이터입니다.\n\n" "**출처**: [example.com](https://example.com)\n\n---\n") def do_web_search(query: str) -> str: try: arts = brave_search(query, 5) except Exception as e: logging.error(f"Brave 검색 실패: {e}") return mock_results(query) if not arts: return mock_results(query) hdr = "# 웹 검색 결과\n아래 정보를 참고해서 답변하세요.\n\n" body = "\n".join( f"### Result {a['index']}: {a['title']}\n\n{a['snippet']}\n\n" f"**출처**: [{a['displayed_link']}]({a['link']})\n\n---\n" for a in arts ) return hdr + body # ──────────────────────────────── 이미지 · 변환 유틸 ──────────────────────── def generate_image(prompt, w=768, h=768, g=3.5, steps=30, seed=3): if not prompt: return None, "프롬프트 부족" try: res = Client(IMAGE_API_URL).predict( prompt=prompt, width=w, height=h, guidance=g, inference_steps=steps, seed=seed, do_img2img=False, init_image=None, image2image_strength=0.8, resize_img=True, api_name="/generate_image") return res[0], f"Seed: {res[1]}" except Exception as e: logging.error(e); return None, str(e) def extract_image_prompt(blog: str, topic: str): sys = f"다음 글로부터 영어 1줄 이미지 프롬프트 생성:\n{topic}" try: res = client.messages.create( model="claude-3-7-sonnet-20250219", max_tokens=80, system=sys, messages=[{"role": "user", "content": blog}] ) return res.content[0].text.strip() except Exception: return f"A professional photo related to {topic}, high quality" def md_to_html(md: str, title="Ginigen Blog"): return f"{title}{markdown.markdown(md)}" def keywords(text: str, top=5): return " ".join(re.sub(r"[^가-힣a-zA-Z0-9\\s]", "", text).split()[:top]) # ──────────────────────────────── Streamlit UI ──────────────────────────── def ginigen_app(): st.title("Ginigen Blog") # 세션 기본값 defaults = dict( ai_model="claude-3-7-sonnet-20250219", messages=[], auto_save=True, generate_image=False, use_web_search=False ) for k, v in defaults.items(): st.session_state.setdefault(k, v) # ── 사이드바 컨트롤 sb = st.sidebar sb.title("대화 기록 관리") sb.toggle("자동 저장", key="auto_save") sb.toggle("이미지 자동 생성", key="generate_image") sb.toggle("웹 검색 사용", key="use_web_search") # ── 최근 블로그 다운로드 (마크다운 / HTML) latest_blog = next( (m["content"] for m in reversed(st.session_state.messages) if m["role"] == "assistant" and m["content"].strip()), None) if latest_blog: title = re.search(r"# (.*?)(\n|$)", latest_blog) title = title.group(1).strip() if title else "blog" sb.subheader("최근 블로그 다운로드") c1, c2 = sb.columns(2) c1.download_button("Markdown", latest_blog, file_name=f"{title}.md", mime="text/markdown") c2.download_button("HTML", md_to_html(latest_blog, title), file_name=f"{title}.html", mime="text/html") # ── JSON 대화 기록 업로드 up = sb.file_uploader("대화 기록 불러오기 (.json)", type=["json"]) if up: try: st.session_state.messages = json.load(up) sb.success("대화 기록 불러오기 완료") except Exception as e: sb.error(f"불러오기 실패: {e}") # ── JSON 대화 기록 다운로드 if sb.button("대화 기록 JSON 다운로드"): sb.download_button("저장", json.dumps(st.session_state.messages, ensure_ascii=False, indent=2), file_name="chat_history.json", mime="application/json") # ── 기존 메시지 렌더링 for m in st.session_state.messages: with st.chat_message(m["role"]): st.markdown(m["content"]) if "image" in m: st.image(m["image"], caption=m.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) with st.chat_message("assistant"): placeholder = st.empty(); answer = "" sys_prompt = get_system_prompt() if st.session_state.use_web_search: with st.spinner("웹 검색 중…"): search_md = do_web_search(keywords(prompt)) sys_prompt += f"\n\n검색 결과:\n{search_md}\n" # Claude 스트리밍 with client.messages.stream( model=st.session_state.ai_model, max_tokens=MAX_TOKENS, system=sys_prompt, messages=[{"role": m["role"], "content": m["content"]} for m in st.session_state.messages] ) as stream: for t in stream.text_stream: answer += t or "" placeholder.markdown(answer + "▌") placeholder.markdown(answer) # 이미지 옵션 if st.session_state.generate_image: with st.spinner("이미지 생성 중…"): ip = extract_image_prompt(answer, prompt) img, cap = generate_image(ip) if img: st.image(img, caption=cap) st.session_state.messages.append( {"role": "assistant", "content": answer, "image": img, "image_caption": cap}) answer_entry_saved = True if not st.session_state.generate_image: st.session_state.messages.append( {"role": "assistant", "content": answer}) # 본문 다운로드 버튼 (MD / HTML) st.subheader("이 블로그 다운로드") b1, b2 = st.columns(2) b1.download_button("Markdown", answer, file_name=f"{prompt[:30]}.md", mime="text/markdown") b2.download_button("HTML", md_to_html(answer, prompt[:30]), file_name=f"{prompt[:30]}.html", mime="text/html") # ── 자동 백업 저장 if st.session_state.auto_save and st.session_state.messages: try: fn = f"chat_history_auto_{datetime.now():%Y%m%d_%H%M%S}.json" with open(fn, "w", encoding="utf-8") as fp: json.dump(st.session_state.messages, fp, ensure_ascii=False, indent=2) except Exception as e: logging.error(f"자동 저장 실패: {e}") # ──────────────────────────────── main / requirements ────────────────────── def main(): ginigen_app() if __name__ == "__main__": # requirements.txt 동적 생성 with open("requirements.txt", "w") as f: f.write("\n".join([ "streamlit>=1.31.0", "anthropic>=0.18.1", "gradio-client>=1.8.0", "requests>=2.32.3", "markdown>=3.5.1", "pillow>=10.1.0" ])) main()