File size: 21,108 Bytes
c99406b
5eb15fa
c99406b
 
1df6810
 
 
c99406b
79f2daf
c99406b
fe49aa3
c99406b
fe49aa3
 
 
79f2daf
a589da1
5eb15fa
a589da1
 
 
 
 
5eb15fa
 
 
a589da1
 
 
 
 
 
 
 
 
 
 
5eb15fa
 
c99406b
 
 
 
 
fe49aa3
79f2daf
c99406b
5eb15fa
 
79f2daf
 
3f26784
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5eb15fa
3f26784
5eb15fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79f2daf
5eb15fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79f2daf
c99406b
a589da1
fe49aa3
c99406b
 
fe49aa3
 
c99406b
fe49aa3
 
 
 
 
 
a589da1
5eb15fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a589da1
5eb15fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fe49aa3
c99406b
5eb15fa
fe49aa3
5eb15fa
 
 
 
 
 
 
 
fe49aa3
 
5eb15fa
fe49aa3
a589da1
5eb15fa
 
 
 
 
 
 
 
 
 
 
fe49aa3
5eb15fa
c99406b
fe49aa3
c99406b
 
 
79f2daf
c99406b
 
 
 
fe49aa3
c99406b
fe49aa3
79f2daf
c99406b
79f2daf
c99406b
 
79f2daf
fe49aa3
79f2daf
c99406b
 
79f2daf
fe49aa3
 
c99406b
79f2daf
c99406b
 
3f26784
c99406b
 
1df6810
c99406b
 
a589da1
1df6810
6db8a66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fe49aa3
c99406b
fe49aa3
5eb15fa
 
 
 
 
a589da1
5eb15fa
 
 
a589da1
5eb15fa
 
6db8a66
5eb15fa
a589da1
 
62d2b15
 
 
 
 
 
 
 
 
 
 
 
 
a589da1
5eb15fa
a589da1
c99406b
5eb15fa
 
 
 
 
c99406b
 
 
 
 
 
 
 
 
 
 
a589da1
c99406b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fe49aa3
c99406b
fe49aa3
 
 
 
 
1df6810
62d2b15
a589da1
 
 
62d2b15
1df6810
62d2b15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1df6810
c99406b
 
1df6810
 
c99406b
1df6810
fe49aa3
 
 
 
 
 
 
 
5eb15fa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
# ──────────────────────────────── Imports ────────────────────────────────
import os, json, re, logging, requests, markdown, time
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

# λΈ”λ‘œκ·Έ ν…œν”Œλ¦Ώ 및 μŠ€νƒ€μΌ μ •μ˜ (ν•œκΈ€ν™”)
BLOG_TEMPLATES = {
    "standard": "ν‘œμ€€ 8단계 ν”„λ ˆμž„μ›Œν¬ λΈ”λ‘œκ·Έ",
    "tutorial": "단계별 νŠœν† λ¦¬μ–Ό ν˜•μ‹",
    "review": "μ œν’ˆ/μ„œλΉ„μŠ€ 리뷰 ν˜•μ‹",
    "storytelling": "μŠ€ν† λ¦¬ν…”λ§ ν˜•μ‹",
    "seo_optimized": "SEO μ΅œμ ν™” λΈ”λ‘œκ·Έ"
}

BLOG_TONES = {
    "professional": "전문적이고 곡식적인 톀",
    "casual": "μΉœκ·Όν•˜κ³  λŒ€ν™”μ²΄ 쀑심 톀",
    "humorous": "μœ λ¨ΈλŸ¬μŠ€ν•œ μ ‘κ·Ό",
    "storytelling": "이야기 μ€‘μ‹¬μ˜ μ ‘κ·Ό"
}

# 예제 λΈ”λ‘œκ·Έ 주제
EXAMPLE_TOPICS = {
    "example1": "2025λ…„ 바뀐 뢀동산 μ„ΈκΈˆ μ œλ„: 일반 가정에 λ―ΈμΉ˜λŠ” 영ν–₯κ³Ό μ ˆμ„Έ μ „λž΅",
    "example2": "2025λ…„ 여름 μ „κ΅­ 지역별 λŒ€ν‘œ μΆ•μ œ 총정리와 μˆ¨μ€ λͺ…μ†Œ μΆ”μ²œ",
    "example3": "2025λ…„ μ£Όλͺ©ν•΄μ•Ό ν•  μ‹ μ„±μž₯ μ‚°μ—… 투자 κ°€μ΄λ“œ: 인곡지λŠ₯ κ΄€λ ¨ 발꡴ μ „λž΅"
}

# ──────────────────────────────── λ‘œκΉ… ──────────────────────────────────────
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(template="standard", tone="professional", word_count=1750) -> str:
    base_prompt = """
당신은 μ „λ¬Έ λΈ”λ‘œκ·Έ μž‘μ„± μ „λ¬Έκ°€μž…λ‹ˆλ‹€. λͺ¨λ“  λΈ”λ‘œκ·Έ κΈ€ μž‘μ„± μš”μ²­μ— λŒ€ν•΄ λ‹€μŒμ˜ 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. μ†Œν†΅ 채널 μ•ˆλ‚΄
"""

    # ν…œν”Œλ¦Ώλ³„ μΆ”κ°€ μ§€μΉ¨
    template_guides = {
        "tutorial": """
이 λΈ”λ‘œκ·ΈλŠ” νŠœν† λ¦¬μ–Ό ν˜•μ‹μœΌλ‘œ μž‘μ„±ν•΄ μ£Όμ„Έμš”:
- λͺ…ν™•ν•œ λͺ©ν‘œμ™€ μ΅œμ’… κ²°κ³Όλ¬Ό λ¨Όμ € μ œμ‹œ
- λ‹¨κ³„λ³„λ‘œ λͺ…ν™•ν•˜κ²Œ κ΅¬λΆ„λœ κ³Όμ • μ„€λͺ…
- 각 λ‹¨κ³„λ§ˆλ‹€ 이미지λ₯Ό μ‚½μž…ν•  μœ„μΉ˜ ν‘œμ‹œ
- μ˜ˆμƒ μ†Œμš” μ‹œκ°„κ³Ό λ‚œμ΄λ„ λͺ…μ‹œ
- ν•„μš”ν•œ λ„κ΅¬λ‚˜ 사전 지식 μ•ˆλ‚΄
- λ¬Έμ œν•΄κ²° 팁과 자주 λ°œμƒν•˜λŠ” μ‹€μˆ˜ 포함
- μ™„λ£Œ ν›„ λ‹€μŒ λ‹¨κ³„λ‚˜ μ‘μš©λ²• μ œμ•ˆ
""",
        
        "review": """
이 λΈ”λ‘œκ·ΈλŠ” 리뷰 ν˜•μ‹μœΌλ‘œ μž‘μ„±ν•΄ μ£Όμ„Έμš”:
- 객관적 사싀과 주관적 평가 ꡬ뢄
- λͺ…ν™•ν•œ 평가 κΈ°μ€€ μ œμ‹œ
- μž₯점과 단점 κ· ν˜•μžˆκ²Œ μ„œμˆ 
- μœ μ‚¬ μ œν’ˆ/μ„œλΉ„μŠ€μ™€ 비ꡐ
- λˆ„κ΅¬μ—κ²Œ μ ν•©ν•œμ§€ νƒ€κ²Ÿ μ„€λͺ…
- ꡬ체적인 μ‚¬μš© κ²½ν—˜κ³Ό κ²°κ³Ό 포함
- μ΅œμ’… μΆ”μ²œ 여뢀와 λŒ€μ•ˆ μ œμ‹œ
""",
        
        "storytelling": """
이 λΈ”λ‘œκ·ΈλŠ” μŠ€ν† λ¦¬ν…”λ§ ν˜•μ‹μœΌλ‘œ μž‘μ„±ν•΄ μ£Όμ„Έμš”:
- μ‹€μ œ μΈλ¬Όμ΄λ‚˜ μ‚¬λ‘€λ‘œ μ‹œμž‘
- 문제 상황과 감정적 μ—°κ²° κ°•ν™”
- κ°ˆλ“±κ³Ό ν•΄κ²°κ³Όμ • μ€‘μ‹¬μ˜ λ‚΄λŸ¬ν‹°λΈŒ
- κ΅ν›ˆκ³Ό 배움을 μžμ—°μŠ€λŸ½κ²Œ 포함
- λ…μžκ°€ 곡감할 수 μžˆλŠ” 감정선 μœ μ§€
- 이야기와 μœ μš©ν•œ μ •λ³΄μ˜ κ· ν˜• μœ μ§€
- λ…μžμ—κ²Œ μžμ‹ μ˜ 이야기λ₯Ό μƒκ°ν•΄λ³΄κ²Œ μœ λ„
""",
        
        "seo_optimized": """
이 λΈ”λ‘œκ·ΈλŠ” SEO μ΅œμ ν™” ν˜•μ‹μœΌλ‘œ μž‘μ„±ν•΄ μ£Όμ„Έμš”:
- 핡심 ν‚€μ›Œλ“œλ₯Ό 제λͺ©, μ†Œμ œλͺ©, 첫 단락에 배치
- κ΄€λ ¨ ν‚€μ›Œλ“œλ₯Ό μžμ—°μŠ€λŸ½κ²Œ 본문에 λΆ„μ‚°
- 300-500자 λΆ„λŸ‰μ˜ λͺ…ν™•ν•œ 단락 ꡬ성
- 질문 ν˜•μ‹μ˜ μ†Œμ œλͺ© ν™œμš©
- λͺ©λ‘, ν‘œ, κ°•μ‘° ν…μŠ€νŠΈ λ“± λ‹€μ–‘ν•œ μ„œμ‹ ν™œμš©
- λ‚΄λΆ€ 링크 μ‚½μž… μœ„μΉ˜ ν‘œμ‹œ
- 2000-3000자 μ΄μƒμ˜ μΆ©λΆ„ν•œ μ½˜ν…μΈ  제곡
"""
    }
    
    # 톀별 μΆ”κ°€ μ§€μΉ¨
    tone_guides = {
        "professional": "전문적이고 κΆŒμœ„μžˆλŠ” μ–΄μ‘°λ‘œ μž‘μ„±ν•˜λ˜, μ „λ¬Έ μš©μ–΄λŠ” 적절히 μ„€λͺ…ν•΄ μ£Όμ„Έμš”. 데이터와 연ꡬ κ²°κ³Όλ₯Ό μ€‘μ‹¬μœΌλ‘œ 논리적 흐름을 μœ μ§€ν•˜μ„Έμš”.",
        "casual": "μΉœκ·Όν•˜κ³  λŒ€ν™”ν•˜λ“― νŽΈμ•ˆν•œ μ–΄μ‘°λ‘œ μž‘μ„±ν•΄ μ£Όμ„Έμš”. '~λ„€μš”', '~ν•΄μš”' 같은 λŒ€ν™”μ²΄λ₯Ό μ‚¬μš©ν•˜κ³ , 개인적 κ²½ν—˜κ³Ό λΉ„μœ λ₯Ό 톡해 λ‚΄μš©μ„ μ „λ‹¬ν•˜μ„Έμš”.",
        "humorous": "μœ λ¨Έμ™€ μž¬μΉ˜μžˆλŠ” ν‘œν˜„μ„ 적절히 ν™œμš©ν•΄ μ£Όμ„Έμš”. μž¬λ―ΈμžˆλŠ” λΉ„μœ λ‚˜ μ˜ˆμ‹œ, κ°€λ²Όμš΄ 농담을 ν¬ν•¨ν•˜λ˜, μ •λ³΄μ˜ μ •ν™•μ„±κ³Ό μœ μš©μ„±μ€ μœ μ§€ν•˜μ„Έμš”.",
        "storytelling": "이야기λ₯Ό λ“€λ €μ£Όλ“― 감성적이고 λͺ°μž…감 μžˆλŠ” ν†€μœΌλ‘œ μž‘μ„±ν•΄ μ£Όμ„Έμš”. 인물, λ°°κ²½, κ°ˆλ“±, 해결과정이 λ‹΄κΈ΄ λ‚΄λŸ¬ν‹°λΈŒ ꡬ쑰λ₯Ό ν™œμš©ν•˜μ„Έμš”."
    }
    
    # μ΅œμ’… ν”„λ‘¬ν”„νŠΈ μ‘°ν•©
    final_prompt = base_prompt
    
    # μ„ νƒλœ ν…œν”Œλ¦Ώ μ§€μΉ¨ μΆ”κ°€
    if template in template_guides:
        final_prompt += "\n" + template_guides[template]
    
    # μ„ νƒλœ 톀 μ§€μΉ¨ μΆ”κ°€
    if tone in tone_guides:
        final_prompt += f"\n\nν†€μ•€λ§€λ„ˆ: {tone_guides[tone]}"
    
    # κΈ€μž 수 μ§€μΉ¨ μΆ”κ°€
    final_prompt += f"\n\nμž‘μ„± μ‹œ μ€€μˆ˜μ‚¬ν•­\n9.1. κΈ€μž 수: {word_count-250}-{word_count+250}자 λ‚΄μ™Έ\n9.2. 문단 길이: 3-4λ¬Έμž₯ 이내\n9.3. μ‹œκ°μ  ꡬ뢄: μ†Œμ œλͺ©, ꡬ뢄선, 번호 λͺ©λ‘ ν™œμš©\n9.4. 데이터: λͺ¨λ“  μ •λ³΄μ˜ 좜처 λͺ…μ‹œ\n9.5. 가독성: λͺ…ν™•ν•œ 단락 ꡬ뢄과 강쑰점 μ‚¬μš©"
    
    return final_prompt

# ──────────────────────────────── Brave Search API ─────────────────────────
def brave_search(query: str, count: int = 20):  # 기본값을 20으둜 λ³€κ²½
    """
    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)}  # 카운트 νŒŒλΌλ―Έν„° 전달
    
    for attempt in range(3):  # μ΅œλŒ€ 3번 μž¬μ‹œλ„
        try:
            r = requests.get(BRAVE_ENDPOINT, headers=headers, params=params, timeout=15)
            r.raise_for_status()
            data = r.json()
            
            # κ²°κ³Ό ν˜•μ‹ 확인 및 λ‘œκΉ…
            logging.info(f"Brave 검색 κ²°κ³Ό 데이터 ꡬ쑰: {list(data.keys())}")
            
            raw = data.get("web", {}).get("results") or data.get("results", [])
            if not raw:
                logging.warning(f"Brave 검색 κ²°κ³Ό μ—†μŒ. 응닡: {data}")
                raise ValueError("검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€")
                
            arts = []
            for i, res in enumerate(raw[:count], 1):  # count만큼 반볡
                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
                })
            
            logging.info(f"Brave 검색 성곡: {len(arts)}개 κ²°κ³Ό")
            return arts
            
        except Exception as e:
            logging.error(f"Brave 검색 μ‹€νŒ¨ (μ‹œλ„ {attempt+1}/3): {e}")
            if attempt < 2:  # λ§ˆμ§€λ§‰ μ‹œλ„κ°€ μ•„λ‹ˆλ©΄ λŒ€κΈ° ν›„ μž¬μ‹œλ„
                time.sleep(2)
            
    return []  # λͺ¨λ“  μ‹œλ„ μ‹€νŒ¨ μ‹œ 빈 λͺ©λ‘ λ°˜ν™˜

def mock_results(query: str) -> str:
    """검색 API μ‹€νŒ¨ μ‹œ 가상 검색 κ²°κ³Ό 제곡"""
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    return (f"# 검색 κ²°κ³Ό λŒ€μ²΄ λ‚΄μš© (생성: {ts})\n\n"
            f"검색 API 호좜이 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€. 주제 '{query}'에 λŒ€ν•΄ κΈ°μ‘΄ 지식을 ν™œμš©ν•΄ λ‹΅λ³€ν•΄ μ£Όμ„Έμš”.\n\n"
            f"λ‹€μŒ λ‚΄μš©μ΄ 도움이 될 수 μžˆμŠ΅λ‹ˆλ‹€:\n\n"
            f"- {query}의 κΈ°λ³Έ κ°œλ…κ³Ό μ€‘μš”μ„±\n"
            f"- 일반적으둜 μ•Œλ €μ§„ κ΄€λ ¨ 톡계와 νŠΈλ Œλ“œ\n"
            f"- ν•΄λ‹Ή μ£Όμ œμ— λŒ€ν•œ μ „λ¬Έκ°€λ“€μ˜ 일반적인 견해\n"
            f"- λ…μžλ“€μ΄ μ‹€μ œλ‘œ κΆκΈˆν•΄ν•  λ§Œν•œ μ§ˆλ¬Έλ“€\n\n"
            f"μ°Έκ³ : 이 λ‚΄μš©μ€ μ‹€μ‹œκ°„ 검색 κ²°κ³Όκ°€ μ•„λ‹Œ λŒ€μ²΄ μ•ˆλ‚΄μž…λ‹ˆλ‹€.\n\n")

def do_web_search(query: str) -> str:
    """μ›Ή 검색 μˆ˜ν–‰ 및 κ²°κ³Ό ν¬λ§·νŒ…"""
    try:
        arts = brave_search(query, 20)  # 여기도 20으둜 λ³€κ²½
        if not arts:
            logging.warning("검색 κ²°κ³Ό μ—†μŒ, λŒ€μ²΄ μ½˜ν…μΈ  μ‚¬μš©")
            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
    except Exception as e:
        logging.error(f"μ›Ή 검색 전체 ν”„λ‘œμ„ΈμŠ€ μ‹€νŒ¨: {str(e)}")
        return mock_results(query)

# ──────────────────────────────── 이미지 Β· λ³€ν™˜ μœ ν‹Έ ────────────────────────
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"<!DOCTYPE html><html><head><title>{title}</title><meta charset='utf-8'></head><body>{markdown.markdown(md)}</body></html>"

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("μ§€λ‹ˆμ   λΈ”λ‘œκ·Έ")

    # μ„Έμ…˜ κΈ°λ³Έκ°’ - μ„Έμ…˜ μƒνƒœκ°€ 이미 μžˆλŠ” 경우 μ„€μ •ν•˜μ§€ μ•ŠμŒ
    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
    if "blog_template" not in st.session_state:
        st.session_state.blog_template = "standard"
    if "blog_tone" not in st.session_state:
        st.session_state.blog_tone = "professional"
    if "word_count" not in st.session_state:
        st.session_state.word_count = 1750

    # ── μ‚¬μ΄λ“œλ°” 컨트둀
    sb = st.sidebar
    sb.title("λΈ”λ‘œκ·Έ μ„€μ •")
    
    # λΈ”λ‘œκ·Έ ν…œν”Œλ¦Ώ 및 μŠ€νƒ€μΌ 선택
    sb.subheader("λΈ”λ‘œκ·Έ μŠ€νƒ€μΌ μ„€μ •")
    sb.selectbox("λΈ”λ‘œκ·Έ ν…œν”Œλ¦Ώ", options=list(BLOG_TEMPLATES.keys()), 
                format_func=lambda x: BLOG_TEMPLATES[x],
                key="blog_template")
    
    sb.selectbox("λΈ”λ‘œκ·Έ 톀", options=list(BLOG_TONES.keys()),
                format_func=lambda x: BLOG_TONES[x],
                key="blog_tone")
    
    sb.slider("λΈ”λ‘œκ·Έ 길이 (단어 수)", 800, 3000, key="word_count")
    
    # 예제 주제 선택
    sb.subheader("예제 주제")
    
    col1, col2, col3 = sb.columns(3)
    
    # μˆ˜μ •: 예제 선택 μ‹œ 직접 μ²˜λ¦¬ν•˜λ„λ‘ λ³€κ²½
    if col1.button("뢀동산 μ„ΈκΈˆ", key="ex1"):
        # 예제 주제λ₯Ό μž…λ ₯으둜 μ¦‰μ‹œ 처리 (rerun 없이)
        process_example(EXAMPLE_TOPICS["example1"])
    
    if col2.button("여름 μΆ•μ œ", key="ex2"):
        process_example(EXAMPLE_TOPICS["example2"])
    
    if col3.button("투자 κ°€μ΄λ“œ", key="ex3"):
        process_example(EXAMPLE_TOPICS["example3"])
    
    sb.subheader("기타 μ„€μ •")
    sb.toggle("μžλ™ μ €μž₯", key="auto_save")
    sb.toggle("이미지 μžλ™ 생성", key="generate_image")
    
    # μ›Ή 검색 ν† κΈ€ (λͺ¨λ‹ˆν„°λ§μ„ μœ„ν•΄ μœ μ§€ν•˜λ˜ 기본값은 False)
    search_enabled = sb.toggle("μ›Ή 검색 μ‚¬μš©", value=False, key="use_web_search")
    if search_enabled:
        st.warning("⚠️ μ›Ή 검색 κΈ°λŠ₯은 ν˜„μž¬ λΆˆμ•ˆμ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€. 검색 κ²°κ³Όκ°€ μ—†μœΌλ©΄ κΈ°λ³Έ μ§€μ‹μœΌλ‘œ λŒ€μ²΄λ©λ‹ˆλ‹€.")

    # ── 졜근 λΈ”λ‘œκ·Έ λ‹€μš΄λ‘œλ“œ (λ§ˆν¬λ‹€μš΄ / 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("λ§ˆν¬λ‹€μš΄", 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", ""))

    # ── μ‚¬μš©μž μž…λ ₯ 처리
    prompt = st.chat_input("무엇을 λ„μ™€λ“œλ¦΄κΉŒμš”?")
    
    if prompt:
        process_input(prompt)


def process_example(topic):
    """예제 주제λ₯Ό 직접 μ²˜λ¦¬ν•˜λŠ” ν•¨μˆ˜ (rerun 없이)"""
    process_input(topic)


def process_input(prompt):
    """μ‚¬μš©μž μž…λ ₯ 처리 ν•¨μˆ˜ (일반 μž…λ ₯κ³Ό 예제 μž…λ ₯ λͺ¨λ‘ 처리)"""
    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(
            template=st.session_state.blog_template,
            tone=st.session_state.blog_tone,
            word_count=st.session_state.word_count
        )

        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)

        # 이미지 μ˜΅μ…˜
        answer_entry_saved = False
        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 answer_entry_saved:
            st.session_state.messages.append(
                {"role": "assistant", "content": answer})

        # λ³Έλ¬Έ λ‹€μš΄λ‘œλ“œ λ²„νŠΌ (MD / HTML)
        st.subheader("이 λΈ”λ‘œκ·Έ λ‹€μš΄λ‘œλ“œ")
        b1, b2 = st.columns(2)
        b1.download_button("λ§ˆν¬λ‹€μš΄", 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()