""" Square Theory Generator (len-flexible) ===================================== 2025‑05‑28 v6 ● LLM 결과가 10개 미만일 때 유연 처리 --------------------------------------------------- 변경 요약 --------- * **문제**: LLM이 10개 미만의 제안을 반환하면 길이 검증 예외 발생. * **해결**: 1. `call_llm()`에서 JSON이 dict 단일 객체 or 1~9개 리스트여도 허용. 2. 리스트가 1개 미만이면 오류, 2~9개는 경고만 표기. 3. Markdown에 실제 개수로 헤더 출력. 실행법 ------ ```bash pip install --upgrade gradio matplotlib openai requests export OPENAI_API_KEY="sk-..." python square_theory_gradio.py ``` """ import os import json import tempfile import urllib.request import gradio as gr import matplotlib.pyplot as plt from matplotlib import patches, font_manager, rcParams from openai import OpenAI # ------------------------------------------------- # 0. 한글 폰트 설정 (다운로드 fallback 포함) # ------------------------------------------------- PREFERRED_FONTS = ["Malgun Gothic", "NanumGothic", "AppleGothic", "DejaVu Sans"] NANUM_URL = ( "https://github.com/google/fonts/raw/main/ofl/nanumgothic/" "NanumGothic-Regular.ttf" ) def _set_korean_font(): available = {f.name for f in font_manager.fontManager.ttflist} for cand in PREFERRED_FONTS: if cand in available: rcParams["font.family"] = cand break else: try: tmp_dir = tempfile.gettempdir() font_path = os.path.join(tmp_dir, "NanumGothic-Regular.ttf") if not os.path.exists(font_path): urllib.request.urlretrieve(NANUM_URL, font_path) font_manager.fontManager.addfont(font_path) rcParams["font.family"] = font_manager.FontProperties(fname=font_path).get_name() except Exception as e: print("[WARN] Font download failed, Korean text may break:", e) rcParams["axes.unicode_minus"] = False _set_korean_font() # ------------------------------------------------- # 1. OpenAI 클라이언트 # ------------------------------------------------- if not os.getenv("OPENAI_API_KEY"): raise EnvironmentError("OPENAI_API_KEY 환경 변수를 설정하세요.") client = OpenAI() # ------------------------------------------------- # 2. Square Diagram # ------------------------------------------------- def draw_square(words): fig, ax = plt.subplots(figsize=(4, 4)) ax.add_patch(patches.Rectangle((0, 0), 1, 1, fill=False, linewidth=2)) ax.text(-0.05, 1.05, str(words.get("tl", "")), ha="right", va="bottom", fontsize=14, fontweight="bold") ax.text(1.05, 1.05, str(words.get("tr", "")), ha="left", va="bottom", fontsize=14, fontweight="bold") ax.text(1.05, -0.05, str(words.get("br", "")), ha="left", va="top", fontsize=14, fontweight="bold") ax.text(-0.05, -0.05, str(words.get("bl", "")), ha="right", va="top", fontsize=14, fontweight="bold") ax.set_xticks([]) ax.set_yticks([]) ax.set_xlim(-0.2, 1.2) ax.set_ylim(-0.2, 1.2) ax.set_aspect("equal") return fig # ------------------------------------------------- # 3. LLM Prompt & Call # ------------------------------------------------- SYSTEM_PROMPT = ( "너는 한국어 카피·브랜드 네이밍 전문가이자 Square Theory 도우미다. " "사용자가 준 하나의 단어(tl)를 기반으로 품질이 뛰어난 순서대로 최대 10개의 제안을 JSON 배열로 반환해라. " "각 배열 원소는 tl, tr, br, bl, top_phrase, bottom_phrase, slogan, brand 필드를 가진다. " "사각형 네 꼭짓점(tl→tr→br→bl)이 자연스레 연결돼야 하고, 배열 첫 원소가 최우수 제안이다. " "결과는 JSON 외 텍스트를 포함하지 않아야 한다." ) def clean_json_block(text: str) -> str: text = text.strip() if text.startswith("```"): text = text.split("\n", 1)[1] if "\n" in text else text[3:] if text.endswith("```"): text = text[:-3] return text.strip() def call_llm(seed: str): resp = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": seed}, ], temperature=0.9, max_tokens=1024, ) raw = resp.choices[0].message.content cleaned = clean_json_block(raw) try: data = json.loads(cleaned) # 단일 객체면 리스트로 변환 if isinstance(data, dict): data = [data] if not isinstance(data, list): raise TypeError("LLM 응답이 리스트가 아님") if len(data) == 0: raise ValueError("LLM이 빈 배열을 반환") except Exception as exc: raise ValueError(f"LLM JSON 파싱 실패: {exc}\n원문 일부: {cleaned[:300]} …") return data # ------------------------------------------------- # 4. Gradio callback # ------------------------------------------------- def generate(seed_word: str): results = call_llm(seed_word) fig = draw_square({k: results[0][k] for k in ("tl", "tr", "br", "bl")}) md_lines = [f"## 총 {len(results)}개 제안\n"] for idx, item in enumerate(results, 1): md_lines.append( f"### {idx}. {item['top_phrase']} / {item['bottom_phrase']}\n" f"- **슬로건**: {item['slogan']}\n" f"- **브랜드 네임**: {item['brand']}\n" f"- (tl={item['tl']}, tr={item['tr']}, br={item['br']}, bl={item['bl']})\n" ) return fig, "\n".join(md_lines) # ------------------------------------------------- # 5. UI # ------------------------------------------------- with gr.Blocks(title="Square Theory – 최고 제안 🇰🇷") as demo: gr.Markdown("""# 🟧 Square Theory 제안 (최대 10개)\n단어 1개 입력 → LLM이 평가·정렬한 사각형/카피/브랜드 네임""") seed = gr.Textbox(label="시드 단어(TL)", placeholder="예: 골든") run = gr.Button("생성") fig_out = gr.Plot(label="1위 사각형") md_out = gr.Markdown(label="제안 목록") run.click(generate, inputs=seed, outputs=[fig_out, md_out]) if __name__ == "__main__": demo.launch()