Compare commits
20 Commits
v3.2
..
e3c963a014
| Author | SHA1 | Date | |
|---|---|---|---|
| e3c963a014 | |||
| af57c3500c | |||
| 0783775cdd | |||
| 2c80ed1a52 | |||
| 9cf1f44a8b | |||
| 9f68133217 | |||
| 3e2405dff9 | |||
| 66be55ba8a | |||
| 6571afc982 | |||
| 6c5c1b9d50 | |||
| 65481eb9e4 | |||
| 8931adeafd | |||
| 0dedb0d7f8 | |||
| 834577fc07 | |||
| b666b67a03 | |||
| 33b0bbd5ee | |||
| 4484fd1cfc | |||
| 09030697ee | |||
| 3d200beba4 | |||
| b5dc961863 |
@@ -73,6 +73,13 @@ logs/
|
||||
# ─── Node.js (대시보드 프론트엔드) ───────────────────────────
|
||||
dashboard/frontend/node_modules/
|
||||
|
||||
# ─── Dashboard 빌드 결과물 (dist는 포함) ──────────────────────
|
||||
!dashboard/frontend/dist
|
||||
!dashboard/frontend/dist/**
|
||||
|
||||
# ─── OS ──────────────────────────────────────────────────────
|
||||
.DS_Store
|
||||
|
||||
# ─── IDE ──────────────────────────────────────────────────────
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# blog-writer
|
||||
|
||||
이 파일은 Claude Code가 어느 경로에서 실행되든 자동으로 로드합니다.
|
||||
|
||||
|
||||
|
||||
## 저장소
|
||||
- Git 서버: Gitea (자체 NAS 운영)
|
||||
- Gitea URL: http://nas.gru.farm:3001
|
||||
- 계정: airkjw
|
||||
- 저장소: blog-writer
|
||||
- Remote: http://nas.gru.farm:3001/airkjw/blog-writer
|
||||
- 토큰: 8a8842a56866feab3a44b9f044491bf0dfc44963
|
||||
|
||||
## NAS ssh 공개키
|
||||
- 아이디: airkjw
|
||||
- 공개키: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICkbFPXF3CHi91UsWIrIsjG8srqceVm1wKrL3K1doM1V
|
||||
- 주소: nas.gru.farm:22
|
||||
- 내부 IP: 192.168.0.17
|
||||
- Docker 명령: sudo /usr/local/bin/docker (NOPASSWD)
|
||||
- Docker Compose: sudo /usr/local/bin/docker compose
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libxml2-dev libxslt-dev libffi-dev libssl-dev \
|
||||
fonts-nanum ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN mkdir -p data/outputs data/topics data/collected data/pending_review \
|
||||
data/published data/discarded data/drafts data/originals \
|
||||
data/images data/analytics logs assets/fonts
|
||||
|
||||
CMD ["python3", "bots/scheduler.py"]
|
||||
@@ -0,0 +1,172 @@
|
||||
/* ── eli blog 커스텀 CSS ── */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap');
|
||||
|
||||
:root {
|
||||
--ai: #7C3AED;
|
||||
--travel: #EA580C;
|
||||
--startup: #2563EB;
|
||||
--review: #0891B2;
|
||||
--tips: #16A34A;
|
||||
--app: #DB2777;
|
||||
--finance: #CA8A04;
|
||||
--fact: #DC2626;
|
||||
}
|
||||
|
||||
body, .body-fauxcolumn-outer {
|
||||
font-family: 'Noto Sans KR', sans-serif !important;
|
||||
background: #F4F6F9 !important;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.header-bar, .header-outer, #header {
|
||||
background: #fff !important;
|
||||
border-bottom: 1px solid #E5E7EB !important;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.06) !important;
|
||||
}
|
||||
.header-inner h1, .header-inner h1 a,
|
||||
.Header h1, .Header h1 a {
|
||||
font-family: 'Noto Sans KR', sans-serif !important;
|
||||
font-size: 22px !important;
|
||||
font-weight: 700 !important;
|
||||
color: #1A1A1A !important;
|
||||
}
|
||||
|
||||
/* 포스트 카드 */
|
||||
.post-outer, .post {
|
||||
background: #fff !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.07) !important;
|
||||
margin-bottom: 24px !important;
|
||||
overflow: hidden !important;
|
||||
transition: transform .2s, box-shadow .2s !important;
|
||||
}
|
||||
.post-outer:hover, .post:hover {
|
||||
transform: translateY(-3px) !important;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.12) !important;
|
||||
}
|
||||
|
||||
/* 포스트 제목 */
|
||||
.post-title a, h3.post-title a {
|
||||
font-size: 18px !important;
|
||||
font-weight: 700 !important;
|
||||
color: #1A1A1A !important;
|
||||
line-height: 1.45 !important;
|
||||
}
|
||||
.post-title a:hover { color: var(--ai) !important; }
|
||||
|
||||
/* 포스트 본문 */
|
||||
.post-body {
|
||||
font-size: 16px !important;
|
||||
line-height: 1.85 !important;
|
||||
color: #374151 !important;
|
||||
}
|
||||
.post-body h2 { font-size: 20px !important; margin: 28px 0 12px !important; font-weight: 700 !important; }
|
||||
.post-body h3 { font-size: 17px !important; margin: 20px 0 10px !important; font-weight: 700 !important; }
|
||||
.post-body p { margin-bottom: 14px !important; }
|
||||
.post-body ul, .post-body ol { margin: 12px 0 12px 22px !important; }
|
||||
.post-body blockquote {
|
||||
border-left: 4px solid var(--ai) !important;
|
||||
background: #f5f3ff !important;
|
||||
padding: 12px 18px !important;
|
||||
border-radius: 0 8px 8px 0 !important;
|
||||
margin: 16px 0 !important;
|
||||
color: #4B5563 !important;
|
||||
}
|
||||
.post-body a { color: var(--ai) !important; }
|
||||
.post-body code {
|
||||
background: #f3f0ff !important;
|
||||
color: var(--ai) !important;
|
||||
padding: 2px 6px !important;
|
||||
border-radius: 4px !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
.post-body pre {
|
||||
background: #1e1e2e !important;
|
||||
color: #cdd6f4 !important;
|
||||
padding: 20px !important;
|
||||
border-radius: 8px !important;
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
.post-body img {
|
||||
border-radius: 8px !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
.post-body table { width: 100% !important; border-collapse: collapse !important; }
|
||||
.post-body th, .post-body td {
|
||||
padding: 10px 14px !important;
|
||||
border: 1px solid #E5E7EB !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
.post-body th { background: #f9fafb !important; font-weight: 700 !important; }
|
||||
|
||||
/* 라벨(카테고리) 배지 */
|
||||
.label-size-1, .label-size-2, .label-size-3,
|
||||
.label-size-4, .label-size-5,
|
||||
.post-labels a, .widget.Label li a {
|
||||
display: inline-block !important;
|
||||
padding: 3px 10px !important;
|
||||
border-radius: 20px !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 700 !important;
|
||||
color: #fff !important;
|
||||
background: var(--ai) !important;
|
||||
text-decoration: none !important;
|
||||
margin-right: 4px !important;
|
||||
}
|
||||
|
||||
/* 메타 정보 */
|
||||
.post-footer, .post-header { color: #6B7280 !important; font-size: 12px !important; }
|
||||
|
||||
/* 사이드바 */
|
||||
.sidebar-outer, .sidebar .widget {
|
||||
background: #fff !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.07) !important;
|
||||
margin-bottom: 20px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.widget-title, h2.title {
|
||||
font-size: 14px !important;
|
||||
font-weight: 700 !important;
|
||||
padding: 16px 20px 12px !important;
|
||||
margin: 0 !important;
|
||||
border-bottom: 2px solid var(--ai) !important;
|
||||
color: #1A1A1A !important;
|
||||
}
|
||||
.sidebar .widget ul { list-style: none !important; padding: 0 !important; margin: 0 !important; }
|
||||
.sidebar .widget ul li { border-bottom: 1px solid #F3F4F6 !important; }
|
||||
.sidebar .widget ul li a {
|
||||
display: block !important;
|
||||
padding: 10px 20px !important;
|
||||
font-size: 13px !important;
|
||||
color: #374151 !important;
|
||||
}
|
||||
.sidebar .widget ul li a:hover { color: var(--ai) !important; background: #faf5ff !important; }
|
||||
|
||||
/* 페이지네이션 */
|
||||
.blog-pager, #blog-pager {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
gap: 12px !important;
|
||||
margin: 32px 0 !important;
|
||||
}
|
||||
.blog-pager a, #blog-pager a {
|
||||
padding: 8px 20px !important;
|
||||
background: #fff !important;
|
||||
border-radius: 8px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #374151 !important;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.08) !important;
|
||||
transition: all .2s !important;
|
||||
}
|
||||
.blog-pager a:hover, #blog-pager a:hover {
|
||||
background: var(--ai) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 640px) {
|
||||
.post-title a { font-size: 16px !important; }
|
||||
.post-body { font-size: 15px !important; }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
blogwriter — Blog Writer CLI package
|
||||
"""
|
||||
__version__ = '3.0.0-dev'
|
||||
@@ -0,0 +1,500 @@
|
||||
"""
|
||||
blogwriter/cli.py
|
||||
Blog Writer MVP CLI - 8 commands
|
||||
|
||||
Usage:
|
||||
bw # Interactive menu
|
||||
bw write [TOPIC] # Write a blog post
|
||||
bw shorts # Create a shorts video
|
||||
bw publish # Publish pending articles
|
||||
bw distribute # Distribute to SNS platforms
|
||||
bw status # Show system status
|
||||
bw doctor # Check API keys and dependencies
|
||||
bw config show # Show resolved configuration
|
||||
bw init # Setup wizard (implemented in PR 10)
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich import print as rprint
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
console = Console()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_resolved_config() -> dict:
|
||||
"""Load resolved config from ConfigResolver."""
|
||||
try:
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
from bots.config_resolver import ConfigResolver
|
||||
return ConfigResolver().resolve()
|
||||
except Exception as e:
|
||||
return {'error': str(e), 'budget': 'free', 'level': 'beginner'}
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.pass_context
|
||||
def app(ctx):
|
||||
"""Blog Writer - AI 콘텐츠 자동화 도구 (v3.0)"""
|
||||
if ctx.invoked_subcommand is None:
|
||||
_interactive_menu()
|
||||
|
||||
|
||||
def _interactive_menu():
|
||||
"""Display interactive menu when no subcommand given."""
|
||||
console.print("\n[bold cyan]Blog Writer v3.0[/bold cyan] - AI 콘텐츠 자동화\n")
|
||||
console.print("사용 가능한 명령어:")
|
||||
commands = [
|
||||
(" bw init", "설정 마법사 - 처음 설정 시 실행"),
|
||||
(" bw write", "블로그 글 작성"),
|
||||
(" bw shorts", "쇼츠 영상 생성"),
|
||||
(" bw publish", "대기 중인 글 발행"),
|
||||
(" bw distribute", "SNS 플랫폼에 배포"),
|
||||
(" bw status", "시스템 상태 확인"),
|
||||
(" bw doctor", "API 키 및 의존성 점검"),
|
||||
(" bw config show","현재 설정 보기"),
|
||||
]
|
||||
for cmd, desc in commands:
|
||||
console.print(f"[green]{cmd:<20}[/green] {desc}")
|
||||
console.print()
|
||||
|
||||
|
||||
@app.command()
|
||||
@click.argument('topic', required=False)
|
||||
@click.option('--publish', '-p', is_flag=True, help='작성 후 즉시 발행')
|
||||
@click.option('--shorts', '-s', is_flag=True, help='쇼츠 영상도 생성')
|
||||
@click.option('--dry-run', is_flag=True, help='실제 API 호출 없이 테스트')
|
||||
def write(topic, publish, shorts, dry_run):
|
||||
"""블로그 글 작성."""
|
||||
cfg = _load_resolved_config()
|
||||
|
||||
if dry_run:
|
||||
console.print("[yellow]Dry run 모드[/yellow] - API 호출 없이 실행")
|
||||
|
||||
if not topic:
|
||||
topic = click.prompt('주제를 입력하세요')
|
||||
|
||||
console.print(f"\n[bold]블로그 글 작성 시작[/bold]")
|
||||
console.print(f"주제: {topic}")
|
||||
console.print(f"글쓰기 엔진: [cyan]{cfg.get('writing', 'auto')}[/cyan]")
|
||||
|
||||
if dry_run:
|
||||
console.print("[yellow]Dry run 완료 (실제 작성 없음)[/yellow]")
|
||||
return
|
||||
|
||||
try:
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
from bots.writer_bot import WriterBot
|
||||
bot = WriterBot()
|
||||
result = bot.write(topic)
|
||||
if result:
|
||||
console.print(f"[green]✓ 작성 완료[/green]: {result.get('title', topic)}")
|
||||
if publish:
|
||||
ctx = click.get_current_context()
|
||||
ctx.invoke(publish_cmd)
|
||||
if shorts:
|
||||
ctx = click.get_current_context()
|
||||
ctx.invoke(shorts_cmd)
|
||||
else:
|
||||
console.print("[red]✗ 작성 실패[/red]")
|
||||
except ImportError:
|
||||
console.print("[red]writer_bot 로드 실패 - bots/ 경로 확인[/red]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]오류: {e}[/red]")
|
||||
|
||||
|
||||
@app.command()
|
||||
@click.option('--slug', help='특정 글 slug 지정')
|
||||
@click.option('--text', '-t', help='직접 텍스트 입력 (글 없이 쇼츠 생성)')
|
||||
@click.option('--dry-run', is_flag=True, help='실제 렌더링 없이 테스트')
|
||||
def shorts(slug, text, dry_run):
|
||||
"""쇼츠 영상 생성."""
|
||||
cfg = _load_resolved_config()
|
||||
|
||||
console.print(f"\n[bold]쇼츠 영상 생성[/bold]")
|
||||
console.print(f"비디오 엔진: [cyan]{cfg.get('video', 'ffmpeg_slides')}[/cyan]")
|
||||
console.print(f"TTS 엔진: [cyan]{cfg.get('tts', 'edge_tts')}[/cyan]")
|
||||
|
||||
if dry_run:
|
||||
console.print("[yellow]Dry run 모드 - 렌더링 없이 설정 확인 완료[/yellow]")
|
||||
return
|
||||
|
||||
try:
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
from bots.shorts_bot import ShortsBot
|
||||
bot = ShortsBot()
|
||||
if text:
|
||||
result = bot.create_from_text(text)
|
||||
elif slug:
|
||||
result = bot.create_from_slug(slug)
|
||||
else:
|
||||
result = bot.create_latest()
|
||||
|
||||
if result:
|
||||
console.print(f"[green]✓ 쇼츠 생성 완료[/green]: {result}")
|
||||
else:
|
||||
console.print("[red]✗ 쇼츠 생성 실패[/red]")
|
||||
except ImportError:
|
||||
console.print("[red]shorts_bot 로드 실패 - bots/ 경로 확인[/red]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]오류: {e}[/red]")
|
||||
|
||||
|
||||
@app.command('publish')
|
||||
def publish_cmd():
|
||||
"""대기 중인 글 발행."""
|
||||
console.print("\n[bold]발행 시작[/bold]")
|
||||
try:
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
from bots.publisher_bot import PublisherBot
|
||||
bot = PublisherBot()
|
||||
result = bot.publish_pending()
|
||||
console.print(f"[green]✓ 발행 완료[/green]: {result} 건")
|
||||
except ImportError:
|
||||
console.print("[red]publisher_bot 로드 실패[/red]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]오류: {e}[/red]")
|
||||
|
||||
|
||||
@app.command()
|
||||
@click.option('--to', help='특정 플랫폼으로만 배포 (예: youtube,tiktok)')
|
||||
def distribute(to):
|
||||
"""SNS 플랫폼에 콘텐츠 배포."""
|
||||
platforms = to.split(',') if to else None
|
||||
console.print(f"\n[bold]배포 시작[/bold]")
|
||||
if platforms:
|
||||
console.print(f"대상: {', '.join(platforms)}")
|
||||
|
||||
try:
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
# Use scheduler or direct bot calls
|
||||
console.print("[yellow]배포 기능은 현재 개발 중입니다[/yellow]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]오류: {e}[/red]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def status():
|
||||
"""시스템 상태 확인 (대시보드 서버 없이 동작)."""
|
||||
console.print("\n[bold]시스템 상태[/bold]\n")
|
||||
|
||||
cfg = _load_resolved_config()
|
||||
|
||||
# Config table
|
||||
table = Table(title="설정 현황", show_header=True)
|
||||
table.add_column("항목", style="cyan")
|
||||
table.add_column("값", style="green")
|
||||
|
||||
table.add_row("예산", cfg.get('budget', 'N/A'))
|
||||
table.add_row("레벨", cfg.get('level', 'N/A'))
|
||||
table.add_row("글쓰기 엔진", str(cfg.get('writing', 'N/A')))
|
||||
table.add_row("TTS 엔진", str(cfg.get('tts', 'N/A')))
|
||||
table.add_row("비디오 엔진", str(cfg.get('video', 'N/A')))
|
||||
table.add_row("플랫폼", ', '.join(cfg.get('platforms', [])))
|
||||
console.print(table)
|
||||
|
||||
# Check data dirs
|
||||
data_dirs = ['data/shorts', 'data/outputs', 'logs']
|
||||
console.print("\n[bold]데이터 디렉터리[/bold]")
|
||||
for d in data_dirs:
|
||||
path = BASE_DIR / d
|
||||
exists = "✓" if path.exists() else "✗"
|
||||
count = len(list(path.glob('*'))) if path.exists() else 0
|
||||
console.print(f" {exists} {d}: {count}개 파일")
|
||||
|
||||
# Prompt tracker stats
|
||||
try:
|
||||
from bots.prompt_layer.prompt_tracker import PromptTracker
|
||||
tracker = PromptTracker()
|
||||
stats = tracker.get_stats()
|
||||
if stats.get('total', 0) > 0:
|
||||
console.print(f"\n[bold]프롬프트 로그[/bold]: {stats['total']}건 기록됨")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.command()
|
||||
def doctor():
|
||||
"""API 키 및 의존성 점검."""
|
||||
console.print("\n[bold]시스템 점검[/bold]\n")
|
||||
|
||||
# Check API keys
|
||||
api_keys = {
|
||||
'OPENAI_API_KEY': 'OpenAI (GPT + TTS)',
|
||||
'ANTHROPIC_API_KEY': 'Anthropic (Claude)',
|
||||
'GEMINI_API_KEY': 'Google Gemini / Veo',
|
||||
'ELEVENLABS_API_KEY': 'ElevenLabs TTS',
|
||||
'KLING_API_KEY': 'Kling AI 영상',
|
||||
'FAL_API_KEY': 'Seedance 2.0 영상',
|
||||
'RUNWAY_API_KEY': 'Runway 영상',
|
||||
'YOUTUBE_CHANNEL_ID': 'YouTube 채널',
|
||||
}
|
||||
|
||||
table = Table(title="API 키 상태", show_header=True)
|
||||
table.add_column("서비스", style="cyan")
|
||||
table.add_column("상태", style="bold")
|
||||
table.add_column("설명")
|
||||
|
||||
for key, desc in api_keys.items():
|
||||
value = os.environ.get(key, '')
|
||||
if value:
|
||||
status_str = "[green]✓ 설정됨[/green]"
|
||||
else:
|
||||
status_str = "[red]✗ 미설정[/red]"
|
||||
table.add_row(desc, status_str, key)
|
||||
|
||||
console.print(table)
|
||||
|
||||
# Check Python dependencies
|
||||
console.print("\n[bold]의존성 점검[/bold]")
|
||||
deps = ['click', 'rich', 'edge_tts', 'requests', 'Pillow', 'dotenv']
|
||||
for dep in deps:
|
||||
try:
|
||||
import importlib
|
||||
importlib.import_module(dep.replace('-', '_').lower().replace('pillow', 'PIL'))
|
||||
console.print(f" [green]✓[/green] {dep}")
|
||||
except ImportError:
|
||||
console.print(f" [red]✗[/red] {dep} - pip install {dep}")
|
||||
|
||||
# Check FFmpeg
|
||||
import subprocess
|
||||
try:
|
||||
r = subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5)
|
||||
if r.returncode == 0:
|
||||
console.print(f" [green]✓[/green] FFmpeg")
|
||||
else:
|
||||
console.print(f" [red]✗[/red] FFmpeg - PATH 확인 필요")
|
||||
except Exception:
|
||||
console.print(f" [red]✗[/red] FFmpeg - 설치 필요")
|
||||
|
||||
|
||||
@app.group()
|
||||
def config():
|
||||
"""설정 관리."""
|
||||
pass
|
||||
|
||||
|
||||
@config.command('show')
|
||||
def config_show():
|
||||
"""현재 해석된 설정 출력."""
|
||||
cfg = _load_resolved_config()
|
||||
|
||||
if 'error' in cfg:
|
||||
console.print(f"[red]설정 로드 오류: {cfg['error']}[/red]")
|
||||
return
|
||||
|
||||
console.print("\n[bold]현재 설정 (ConfigResolver 기준)[/bold]\n")
|
||||
|
||||
table = Table(show_header=True)
|
||||
table.add_column("항목", style="cyan")
|
||||
table.add_column("값", style="green")
|
||||
|
||||
for key, value in cfg.items():
|
||||
if isinstance(value, list):
|
||||
value = ', '.join(str(v) for v in value)
|
||||
elif isinstance(value, dict):
|
||||
value = json.dumps(value, ensure_ascii=False)
|
||||
table.add_row(key, str(value))
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@app.command()
|
||||
def init():
|
||||
"""설정 마법사 - 처음 설치 시 실행."""
|
||||
console.print("\n[bold cyan]=== Blog Writer 설정 마법사 ===[/bold cyan]\n")
|
||||
console.print("몇 가지 질문에 답하면 자동으로 설정이 완성됩니다.\n")
|
||||
|
||||
profile = {}
|
||||
|
||||
# Step 1: Budget
|
||||
console.print("[bold]1. 예산 설정[/bold]")
|
||||
console.print(" free — API 키 없이 무료 도구만 사용")
|
||||
console.print(" low — OpenAI 키 정도만 있으면 사용 가능")
|
||||
console.print(" medium — ElevenLabs TTS + AI 영상 사용")
|
||||
console.print(" premium — 최고 품질 모든 엔진 사용")
|
||||
budget = click.prompt(
|
||||
"예산 선택",
|
||||
type=click.Choice(['free', 'low', 'medium', 'premium']),
|
||||
default='free'
|
||||
)
|
||||
profile['budget'] = budget
|
||||
|
||||
# Step 2: Level
|
||||
console.print("\n[bold]2. 사용자 레벨[/bold]")
|
||||
console.print(" beginner — 처음 사용하는 분")
|
||||
console.print(" intermediate — 어느 정도 익숙한 분")
|
||||
console.print(" advanced — 설정을 직접 다루는 분")
|
||||
level = click.prompt(
|
||||
"레벨 선택",
|
||||
type=click.Choice(['beginner', 'intermediate', 'advanced']),
|
||||
default='beginner'
|
||||
)
|
||||
profile['level'] = level
|
||||
|
||||
# Step 3: Platforms
|
||||
console.print("\n[bold]3. 발행 플랫폼[/bold]")
|
||||
console.print("어디에 콘텐츠를 올리실 건가요? (여러 개 선택 가능)")
|
||||
platforms = []
|
||||
platform_choices = [
|
||||
('youtube', 'YouTube (쇼츠)'),
|
||||
('tiktok', 'TikTok'),
|
||||
('instagram', 'Instagram (릴스)'),
|
||||
('x', 'X (트위터)'),
|
||||
('blog', '블로그 (Blogger)'),
|
||||
]
|
||||
for key, name in platform_choices:
|
||||
if click.confirm(f" {name}?", default=(key == 'youtube')):
|
||||
platforms.append(key)
|
||||
|
||||
if not platforms:
|
||||
platforms = ['youtube'] # default
|
||||
profile['platforms'] = platforms
|
||||
|
||||
# Step 4: Services (free web clients)
|
||||
console.print("\n[bold]4. 무료 서비스 설정[/bold]")
|
||||
services = {}
|
||||
|
||||
if click.confirm(" ChatGPT Pro(Web) 사용 중이신가요? (글쓰기에 사용)", default=False):
|
||||
services['openclaw'] = True
|
||||
console.print(" [yellow]→ OpenClaw 에이전트를 ChatGPT에 등록해야 합니다[/yellow]")
|
||||
else:
|
||||
services['openclaw'] = False
|
||||
|
||||
if click.confirm(" Claude Max(Web) 사용 중이신가요?", default=False):
|
||||
services['claude_web'] = True
|
||||
else:
|
||||
services['claude_web'] = False
|
||||
|
||||
if click.confirm(" Google Gemini Pro(Web) 사용 중이신가요?", default=False):
|
||||
services['gemini_web'] = True
|
||||
else:
|
||||
services['gemini_web'] = False
|
||||
|
||||
profile['services'] = services
|
||||
|
||||
# Step 5: API Keys
|
||||
console.print("\n[bold]5. API 키 설정[/bold]")
|
||||
console.print("[dim]키를 지금 입력하면 .env 파일에 저장됩니다.[/dim]")
|
||||
console.print("[dim]나중에 .env 파일을 직접 편집해도 됩니다.[/dim]\n")
|
||||
|
||||
env_updates = {}
|
||||
|
||||
api_key_prompts = [
|
||||
('OPENAI_API_KEY', 'OpenAI API 키 (GPT + TTS)', budget in ('low', 'medium', 'premium')),
|
||||
('ANTHROPIC_API_KEY', 'Anthropic API 키 (Claude)', budget in ('medium', 'premium')),
|
||||
('GEMINI_API_KEY', 'Google Gemini API 키 (Veo 영상)', budget in ('medium', 'premium')),
|
||||
('ELEVENLABS_API_KEY', 'ElevenLabs TTS 키', budget in ('medium', 'premium')),
|
||||
('KLING_API_KEY', 'Kling AI 영상 키 (무료 크레딧 있음)', True),
|
||||
('FAL_API_KEY', 'fal.ai API 키 (Seedance 2.0)', budget in ('medium', 'premium')),
|
||||
]
|
||||
|
||||
for env_key, description, suggested in api_key_prompts:
|
||||
existing = os.environ.get(env_key, '')
|
||||
if existing:
|
||||
console.print(f" [green]✓[/green] {description}: 이미 설정됨")
|
||||
continue
|
||||
|
||||
if suggested or click.confirm(f" {description} 입력하시겠어요?", default=False):
|
||||
value = click.prompt(
|
||||
f" {env_key}",
|
||||
default='',
|
||||
show_default=False,
|
||||
hide_input=True,
|
||||
)
|
||||
if value.strip():
|
||||
env_updates[env_key] = value.strip()
|
||||
|
||||
# Step 6: Engine preferences
|
||||
console.print("\n[bold]6. 엔진 설정 (선택 — 기본값: 자동)[/bold]")
|
||||
profile['engines'] = {
|
||||
'writing': {'provider': 'auto'},
|
||||
'tts': {'provider': 'auto'},
|
||||
'video': {'provider': 'auto'},
|
||||
'image': {'provider': 'auto'},
|
||||
}
|
||||
|
||||
if click.confirm(" 엔진을 직접 지정하시겠어요? (아니면 자동)", default=False):
|
||||
# Writing engine
|
||||
console.print("\n [bold]글쓰기 엔진:[/bold] openclaw, claude_web, claude, gemini, auto")
|
||||
writing_eng = click.prompt(" 글쓰기 엔진", default='auto')
|
||||
profile['engines']['writing']['provider'] = writing_eng
|
||||
|
||||
# TTS engine
|
||||
console.print(" [bold]TTS 엔진:[/bold] elevenlabs, openai_tts, edge_tts, auto")
|
||||
tts_eng = click.prompt(" TTS 엔진", default='auto')
|
||||
profile['engines']['tts']['provider'] = tts_eng
|
||||
|
||||
# Save profile
|
||||
profile['_comment'] = '사용자 의도 설정 - bw init으로 생성/업데이트'
|
||||
profile['_updated'] = __import__('datetime').datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
profile_path = BASE_DIR / 'config' / 'user_profile.json'
|
||||
profile_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
profile_path.write_text(
|
||||
__import__('json').dumps(profile, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8'
|
||||
)
|
||||
|
||||
# Update .env if new keys were entered
|
||||
if env_updates:
|
||||
_update_env_file(env_updates)
|
||||
|
||||
console.print("\n[bold green]✓ 설정 완료![/bold green]")
|
||||
console.print(f" user_profile.json 저장됨: {profile_path}")
|
||||
if env_updates:
|
||||
console.print(f" .env 업데이트됨: {len(env_updates)}개 키")
|
||||
console.print("\n다음 명령어로 시작하세요:")
|
||||
console.print(" [cyan]bw doctor[/cyan] — 설정 확인")
|
||||
console.print(" [cyan]bw write[/cyan] — 첫 글 작성")
|
||||
console.print(" [cyan]bw status[/cyan] — 시스템 현황\n")
|
||||
|
||||
|
||||
def _update_env_file(updates: dict) -> None:
|
||||
"""
|
||||
Add or update key-value pairs in .env file.
|
||||
Creates .env if it doesn't exist.
|
||||
"""
|
||||
env_path = BASE_DIR / '.env'
|
||||
|
||||
# Read existing lines
|
||||
existing_lines = []
|
||||
if env_path.exists():
|
||||
existing_lines = env_path.read_text(encoding='utf-8').splitlines()
|
||||
|
||||
# Update existing keys or append new ones
|
||||
updated_keys = set()
|
||||
new_lines = []
|
||||
for line in existing_lines:
|
||||
if '=' in line and not line.startswith('#'):
|
||||
key = line.split('=', 1)[0].strip()
|
||||
if key in updates:
|
||||
new_lines.append(f'{key}={updates[key]}')
|
||||
updated_keys.add(key)
|
||||
continue
|
||||
new_lines.append(line)
|
||||
|
||||
# Append new keys
|
||||
for key, value in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_path.write_text('\n'.join(new_lines) + '\n', encoding='utf-8')
|
||||
logger.info(f'[설정] .env 업데이트: {list(updates.keys())}')
|
||||
|
||||
|
||||
# Entry point
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
app()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -21,7 +21,7 @@ from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
DATA_DIR = BASE_DIR / 'data'
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ from typing import Optional
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
ASSIST_DIR = BASE_DIR / 'data' / 'assist'
|
||||
|
||||
+22
-7
@@ -17,7 +17,7 @@ import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
CONFIG_DIR = BASE_DIR / 'config'
|
||||
@@ -95,7 +95,7 @@ def calc_freshness_score(published_at: datetime | None, max_score: int = 20) ->
|
||||
return int(max_score * ratio)
|
||||
|
||||
|
||||
def calc_korean_relevance(text: str, rules: dict) -> int:
|
||||
def calc_korean_relevance(text: str, rules: dict, rss_category: str = '') -> int:
|
||||
"""한국 독자 관련성 점수"""
|
||||
max_score = rules['scoring']['korean_relevance']['max']
|
||||
keywords = rules['scoring']['korean_relevance']['keywords']
|
||||
@@ -107,11 +107,14 @@ def calc_korean_relevance(text: str, rules: dict) -> int:
|
||||
base = 15 # 한국어 텍스트면 기본 15점
|
||||
elif korean_ratio >= 0.05:
|
||||
base = 8
|
||||
elif rss_category:
|
||||
# RSS 카테고리가 지정된 영문 소스는 큐레이션된 것이므로 기본점수 부여
|
||||
base = 10
|
||||
else:
|
||||
base = 0
|
||||
|
||||
# 브랜드/지역 키워드 보너스
|
||||
matched = sum(1 for kw in keywords if kw in text)
|
||||
matched = sum(1 for kw in keywords if kw.lower() in text.lower())
|
||||
bonus = min(matched * 5, max_score - base)
|
||||
|
||||
return min(base + bonus, max_score)
|
||||
@@ -199,7 +202,11 @@ def apply_discard_rules(item: dict, rules: dict, published_titles: list[str]) ->
|
||||
|
||||
|
||||
def assign_corner(item: dict, topic_type: str) -> str:
|
||||
"""글감에 코너 배정"""
|
||||
"""글감에 코너 배정 — RSS 카테고리가 있으면 우선 사용"""
|
||||
rss_cat = item.get('_rss_category', '')
|
||||
if rss_cat:
|
||||
return rss_cat
|
||||
|
||||
title = item.get('topic', '').lower()
|
||||
source = item.get('source', 'rss').lower()
|
||||
|
||||
@@ -227,7 +234,7 @@ def calculate_quality_score(item: dict, rules: dict) -> int:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
kr_score = calc_korean_relevance(text, rules)
|
||||
kr_score = calc_korean_relevance(text, rules, rss_category=item.get('_rss_category', ''))
|
||||
fresh_score = calc_freshness_score(pub_at)
|
||||
# search_demand: pytrends 연동 후 실제값 사용 (RSS 기본값 12)
|
||||
search_score = item.get('search_demand_score', 12)
|
||||
@@ -379,9 +386,15 @@ def collect_rss_feeds(sources_cfg: dict) -> list[dict]:
|
||||
pub_at = None
|
||||
if hasattr(entry, 'published_parsed') and entry.published_parsed:
|
||||
pub_at = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).isoformat()
|
||||
title_text = entry.get('title', '')
|
||||
desc_text = entry.get('summary', '') or entry.get('description', '')
|
||||
# 한국어 문자가 거의 없으면 영문 소스로 판단
|
||||
combined = title_text + desc_text
|
||||
kr_chars = sum(1 for c in combined if '\uac00' <= c <= '\ud7a3')
|
||||
is_english = kr_chars / max(len(combined), 1) < 0.05
|
||||
items.append({
|
||||
'topic': entry.get('title', ''),
|
||||
'description': entry.get('summary', '') or entry.get('description', ''),
|
||||
'topic': title_text,
|
||||
'description': desc_text,
|
||||
'source': 'rss',
|
||||
'source_name': feed_cfg.get('name', ''),
|
||||
'source_url': entry.get('link', ''),
|
||||
@@ -389,6 +402,8 @@ def collect_rss_feeds(sources_cfg: dict) -> list[dict]:
|
||||
'search_demand_score': 8,
|
||||
'topic_type': 'trending',
|
||||
'_trust_override': trust,
|
||||
'_rss_category': feed_cfg.get('category', ''),
|
||||
'is_english': is_english,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"RSS 수집 실패 ({url}): {e}")
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
bots/config_resolver.py [NEW]
|
||||
|
||||
Single source of truth at runtime.
|
||||
Merges user_profile + engine.json + env.
|
||||
|
||||
Priority: user_profile > engine.json > hardcoded defaults
|
||||
Missing API key → auto-downgrade to free alternative
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Base directory of the project (one level up from bots/)
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Fallback engine for each category when all else fails
|
||||
FALLBACKS = {
|
||||
'writing': 'openclaw',
|
||||
'tts': 'edge_tts',
|
||||
'video': 'ffmpeg_slides',
|
||||
'image': 'external',
|
||||
}
|
||||
|
||||
# Budget-to-engine priority lists per category
|
||||
BUDGET_ENGINE_MAP = {
|
||||
'free': {
|
||||
'writing': ['openclaw', 'claude_web', 'gemini_web'],
|
||||
'tts': ['kokoro', 'edge_tts'],
|
||||
'video': ['kling_free', 'ffmpeg_slides'],
|
||||
'image': ['external'],
|
||||
},
|
||||
'low': {
|
||||
'writing': ['openclaw', 'claude_web', 'claude'],
|
||||
'tts': ['openai_tts', 'kokoro', 'edge_tts'],
|
||||
'video': ['kling_free', 'veo3', 'seedance2', 'ffmpeg_slides'],
|
||||
'image': ['dalle', 'external'],
|
||||
},
|
||||
'medium': {
|
||||
'writing': ['openclaw', 'claude', 'gemini'],
|
||||
'tts': ['elevenlabs', 'openai_tts', 'cosyvoice2', 'edge_tts'],
|
||||
'video': ['kling_free', 'veo3', 'seedance2', 'runway', 'ffmpeg_slides'],
|
||||
'image': ['dalle', 'external'],
|
||||
},
|
||||
'premium': {
|
||||
'writing': ['openclaw', 'claude', 'gemini'],
|
||||
'tts': ['elevenlabs', 'openai_tts', 'cosyvoice2'],
|
||||
'video': ['kling_free', 'veo3', 'seedance2', 'runway', 'kling_pro'],
|
||||
'image': ['dalle', 'midjourney', 'external'],
|
||||
},
|
||||
}
|
||||
|
||||
# Engine registry: local=True means no API key required (free/local)
|
||||
ENGINE_REGISTRY = {
|
||||
'kokoro': {'local': True},
|
||||
'edge_tts': {'local': True},
|
||||
'ffmpeg_slides': {'local': True},
|
||||
'external': {'local': True},
|
||||
'cosyvoice2': {'local': True},
|
||||
'openclaw': {'local': True},
|
||||
'claude_web': {'local': True},
|
||||
'gemini_web': {'local': True},
|
||||
# API-based engines
|
||||
'elevenlabs': {'local': False},
|
||||
'openai_tts': {'local': False},
|
||||
'claude': {'local': False},
|
||||
'gemini': {'local': False},
|
||||
'kling_free': {'local': False},
|
||||
'kling_pro': {'local': False},
|
||||
'veo3': {'local': False},
|
||||
'seedance2': {'local': False},
|
||||
'runway': {'local': False},
|
||||
'dalle': {'local': False},
|
||||
'midjourney': {'local': False},
|
||||
}
|
||||
|
||||
# Map from engine name to required environment variable
|
||||
ENGINE_API_KEY_MAP = {
|
||||
'elevenlabs': 'ELEVENLABS_API_KEY',
|
||||
'openai_tts': 'OPENAI_API_KEY',
|
||||
'claude': 'ANTHROPIC_API_KEY',
|
||||
'gemini': 'GEMINI_API_KEY',
|
||||
'kling_free': 'KLING_API_KEY',
|
||||
'kling_pro': 'KLING_API_KEY',
|
||||
'veo3': 'GEMINI_API_KEY',
|
||||
'seedance2': 'FAL_API_KEY',
|
||||
'runway': 'RUNWAY_API_KEY',
|
||||
'dalle': 'OPENAI_API_KEY',
|
||||
'midjourney': 'MIDJOURNEY_API_KEY',
|
||||
}
|
||||
|
||||
|
||||
class ConfigResolver:
|
||||
"""
|
||||
Single source of truth at runtime.
|
||||
Merges user_profile + engine.json + env.
|
||||
|
||||
Priority: user_profile > engine.json > hardcoded defaults
|
||||
Missing API key → auto-downgrade to free alternative
|
||||
"""
|
||||
|
||||
def resolve(self) -> dict:
|
||||
"""Resolve and return the full runtime configuration."""
|
||||
profile = self._load('config/user_profile.json')
|
||||
engine = self._load('config/engine.json')
|
||||
|
||||
resolved = {
|
||||
'writing': self._resolve_engine('writing', profile),
|
||||
'tts': self._resolve_engine('tts', profile),
|
||||
'video': self._resolve_engine('video', profile),
|
||||
'image': self._resolve_engine('image', profile),
|
||||
'platforms': self._resolve_platforms(profile),
|
||||
'budget': profile.get('budget', 'free'),
|
||||
'level': profile.get('level', 'beginner'),
|
||||
}
|
||||
return resolved
|
||||
|
||||
def _load(self, path: str) -> dict:
|
||||
"""Load JSON from BASE_DIR/path; return {} if file not found or invalid."""
|
||||
full_path = BASE_DIR / path
|
||||
try:
|
||||
with open(full_path, encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"[설정] {path} 없음 — 기본값 사용", file=sys.stderr)
|
||||
return {}
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[설정] {path} 파싱 오류: {e} — 기본값 사용", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
def _has_api_key(self, engine_name: str) -> bool:
|
||||
"""
|
||||
Check whether the required API key env var for the given engine is set.
|
||||
Engines not in ENGINE_API_KEY_MAP are local/free and always available.
|
||||
"""
|
||||
# Local/free engines never need a key
|
||||
engine_info = ENGINE_REGISTRY.get(engine_name, {})
|
||||
if engine_info.get('local', False):
|
||||
return True
|
||||
|
||||
env_var = ENGINE_API_KEY_MAP.get(engine_name)
|
||||
if env_var is None:
|
||||
# Unknown engine — treat as available (graceful degradation)
|
||||
logger.warning(
|
||||
"Unknown engine '%s': not in ENGINE_API_KEY_MAP or ENGINE_REGISTRY as local; "
|
||||
"treating as available.",
|
||||
engine_name,
|
||||
)
|
||||
return True
|
||||
|
||||
value = os.environ.get(env_var, '').strip()
|
||||
return len(value) > 0
|
||||
|
||||
def _resolve_engine(self, category: str, profile: dict) -> dict:
|
||||
"""
|
||||
Resolve the active engine for a category.
|
||||
|
||||
Steps:
|
||||
1. Check user's chosen provider from profile
|
||||
2. Check if that provider's API key exists in env
|
||||
3. If not, auto-switch to next available alternative within budget
|
||||
4. If all fail, use hardcoded free fallback
|
||||
|
||||
Returns dict with 'provider' and 'auto_selected' flag.
|
||||
"""
|
||||
budget = profile.get('budget', 'free')
|
||||
if budget not in BUDGET_ENGINE_MAP:
|
||||
logger.warning(
|
||||
"Invalid budget value '%s' from profile; falling back to 'free'.",
|
||||
budget,
|
||||
)
|
||||
budget = 'free'
|
||||
candidate_list = BUDGET_ENGINE_MAP[budget].get(category, [])
|
||||
|
||||
# Determine user's preferred provider
|
||||
engines_section = profile.get('engines', {})
|
||||
category_cfg = engines_section.get(category, {})
|
||||
user_provider = category_cfg.get('provider', 'auto') if isinstance(category_cfg, dict) else 'auto'
|
||||
|
||||
# If user explicitly set a provider (not "auto"), try it first
|
||||
if user_provider and user_provider != 'auto':
|
||||
if self._has_api_key(user_provider):
|
||||
print(f"[설정] {category}: 사용자 지정 '{user_provider}' 사용")
|
||||
return {'provider': user_provider, 'auto_selected': False}
|
||||
else:
|
||||
print(f"[설정] {category}: '{user_provider}' API 키 없음 — 자동 선택으로 전환")
|
||||
|
||||
# Auto-select: iterate budget-appropriate candidates in priority order
|
||||
for engine_name in candidate_list:
|
||||
if self._has_api_key(engine_name):
|
||||
auto = (user_provider == 'auto')
|
||||
if not auto:
|
||||
print(f"[설정] {category}: '{engine_name}'으로 자동 전환")
|
||||
else:
|
||||
print(f"[설정] {category}: 자동 선택 → '{engine_name}'")
|
||||
return {'provider': engine_name, 'auto_selected': True}
|
||||
|
||||
# Last resort: hardcoded free fallback
|
||||
fallback = FALLBACKS.get(category, 'external')
|
||||
print(f"[설정] {category}: 모든 엔진 실패 — 기본 폴백 '{fallback}' 사용")
|
||||
return {'provider': fallback, 'auto_selected': True}
|
||||
|
||||
def _resolve_platforms(self, profile: dict) -> list:
|
||||
"""Return the list of target publishing platforms from user profile."""
|
||||
platforms = profile.get('platforms', [])
|
||||
if not isinstance(platforms, list):
|
||||
return [str(platforms)] if platforms else []
|
||||
return platforms
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standalone test entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _run_test():
|
||||
"""Print resolved config for manual verification."""
|
||||
print("=" * 60)
|
||||
print("ConfigResolver 테스트 실행")
|
||||
print("=" * 60)
|
||||
|
||||
resolver = ConfigResolver()
|
||||
config = resolver.resolve()
|
||||
|
||||
print("\n[결과] 런타임 설정:")
|
||||
print(json.dumps(config, ensure_ascii=False, indent=2))
|
||||
|
||||
print("\n[요약]")
|
||||
print(f" 예산 등급 : {config['budget']}")
|
||||
print(f" 사용자 레벨: {config['level']}")
|
||||
print(f" 플랫폼 : {config['platforms']}")
|
||||
for cat in ('writing', 'tts', 'video', 'image'):
|
||||
eng = config[cat]
|
||||
flag = '(자동)' if eng.get('auto_selected') else '(지정)'
|
||||
print(f" {cat:10s}: {eng['provider']} {flag}")
|
||||
|
||||
print("=" * 60)
|
||||
print("테스트 완료")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if '--test' in sys.argv:
|
||||
_run_test()
|
||||
else:
|
||||
print("사용법: python -m bots.config_resolver --test")
|
||||
@@ -32,7 +32,7 @@ from typing import Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
LOG_DIR = BASE_DIR / 'logs'
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
bots/converters/smart_video_router.py [NEW]
|
||||
|
||||
Budget-aware video engine selection and fallback router.
|
||||
|
||||
Selection logic:
|
||||
1. Kling free credits remaining? → use Kling
|
||||
2. Budget allows paid? → cheapest quality engine
|
||||
3. Daily limit hit? → FFmpeg fallback
|
||||
4. Any engine fails? → next in priority (no retry on same)
|
||||
|
||||
Usage:
|
||||
from bots.converters.smart_video_router import SmartVideoRouter
|
||||
router = SmartVideoRouter(resolved_config)
|
||||
engine = router.select(duration_sec=30, needs_audio=True)
|
||||
path = router.generate(prompt, engine, '/tmp/out.mp4')
|
||||
|
||||
Test mode:
|
||||
python -m bots.converters.smart_video_router --test
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv() # load .env from current directory or parents
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
LOG_DIR = BASE_DIR / 'logs'
|
||||
DATA_DIR = BASE_DIR / 'data'
|
||||
STATE_FILE = DATA_DIR / 'video_router_state.json'
|
||||
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.handlers:
|
||||
handler = logging.FileHandler(LOG_DIR / 'smart_video_router.log', encoding='utf-8')
|
||||
handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
|
||||
logger.addHandler(handler)
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
class SmartVideoRouter:
|
||||
"""
|
||||
Budget-aware video engine selection and fallback.
|
||||
|
||||
Logic:
|
||||
1. Kling free credits remaining? → use Kling
|
||||
2. Budget allows paid? → cheapest quality engine
|
||||
3. Daily limit hit? → FFmpeg fallback
|
||||
4. Any engine fails? → next in priority (no retry on same)
|
||||
"""
|
||||
|
||||
def __init__(self, resolved_config: dict):
|
||||
"""
|
||||
resolved_config: output from ConfigResolver.resolve(), or raw engine.json dict.
|
||||
Expects video_generation key with provider/options structure.
|
||||
"""
|
||||
video_cfg = resolved_config.get('video_generation', {})
|
||||
opts = video_cfg.get('options', {})
|
||||
router_cfg = opts.get('smart_router', {})
|
||||
|
||||
self.priority: list = router_cfg.get(
|
||||
'priority', ['kling_free', 'veo3', 'seedance2', 'ffmpeg_slides']
|
||||
)
|
||||
self.daily_cost_limit_usd: float = router_cfg.get('daily_cost_limit_usd', 0.50)
|
||||
self.prefer_free_first: bool = router_cfg.get('prefer_free_first', True)
|
||||
self.fallback_engine: str = router_cfg.get('fallback', 'ffmpeg_slides')
|
||||
|
||||
self.engine_opts: dict = opts # all engine option blocks
|
||||
self._cfg: dict = video_cfg # full video_generation config block
|
||||
self.state: dict = self._get_state()
|
||||
|
||||
# ── State management ────────────────────────────────────
|
||||
|
||||
def _get_state(self) -> dict:
|
||||
"""Load daily state from disk; reset if date has changed."""
|
||||
today = str(date.today())
|
||||
default = {
|
||||
'date': today,
|
||||
'cost_usd': 0.0,
|
||||
'kling_credits_used': 0,
|
||||
}
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
saved = json.loads(STATE_FILE.read_text(encoding='utf-8'))
|
||||
if saved.get('date') == today:
|
||||
return saved
|
||||
# New day — reset counters, keep structure
|
||||
logger.info(f"날짜 변경 감지 ({saved.get('date')} → {today}): 라우터 상태 초기화")
|
||||
except Exception as e:
|
||||
logger.warning(f"상태 파일 읽기 실패: {e}")
|
||||
self._save_state(default)
|
||||
return default
|
||||
|
||||
def _save_state(self, state: Optional[dict] = None) -> None:
|
||||
"""Persist router state to data/video_router_state.json."""
|
||||
target = state if state is not None else self.state
|
||||
try:
|
||||
STATE_FILE.write_text(
|
||||
json.dumps(target, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"상태 파일 저장 실패: {e}")
|
||||
|
||||
# ── Engine availability checks ───────────────────────────
|
||||
|
||||
def _has_api_key(self, engine_name_or_cfg) -> bool:
|
||||
"""Return True if the engine's API key env var is set and non-empty.
|
||||
|
||||
Accepts either an engine name string or a dict with 'api_key_env' key.
|
||||
"""
|
||||
if isinstance(engine_name_or_cfg, dict):
|
||||
cfg = engine_name_or_cfg
|
||||
else:
|
||||
cfg = self.engine_opts.get(engine_name_or_cfg, {})
|
||||
key_env = cfg.get('api_key_env', '')
|
||||
if not key_env:
|
||||
# ffmpeg_slides has no API key requirement
|
||||
return True
|
||||
return bool(os.getenv(key_env, '').strip())
|
||||
|
||||
def _kling_credits_available(self) -> bool:
|
||||
"""Return True if Kling free credits are still available today."""
|
||||
kling_cfg = self.engine_opts.get('kling_free', {})
|
||||
daily_credits = kling_cfg.get('free_daily_credits', 66)
|
||||
used = self.state.get('kling_credits_used', 0)
|
||||
return used < daily_credits
|
||||
|
||||
def _budget_allows(self, engine_name: str, duration_sec: float) -> bool:
|
||||
"""Return True if engine cost fits within remaining daily budget."""
|
||||
cfg = self.engine_opts.get(engine_name, {})
|
||||
cost_per_sec = cfg.get('cost_per_sec', 0)
|
||||
if cost_per_sec == 0:
|
||||
return True
|
||||
estimated_cost = cost_per_sec * duration_sec
|
||||
spent = self.state.get('cost_usd', 0.0)
|
||||
return (spent + estimated_cost) <= self.daily_cost_limit_usd
|
||||
|
||||
# ── Public API ────────────────────────────────────────────
|
||||
|
||||
def select(self, duration_sec: float, needs_audio: bool) -> str:
|
||||
"""
|
||||
Select best available engine for the given clip duration.
|
||||
Returns engine name string (never empty — falls back to ffmpeg_slides).
|
||||
"""
|
||||
self.state = self._get_state() # refresh in case of date change
|
||||
|
||||
for engine in self.priority:
|
||||
if engine == 'ffmpeg_slides':
|
||||
logger.info("영상 라우터: ffmpeg_slides 선택 (최종 폴백)")
|
||||
return 'ffmpeg_slides'
|
||||
|
||||
if engine == 'kling_free':
|
||||
if self._has_api_key('kling_free') and self._kling_credits_available():
|
||||
logger.info("영상 라우터: kling_free 선택 (무료 크레딧 잔여)")
|
||||
return 'kling_free'
|
||||
continue
|
||||
|
||||
# Paid engines (veo3, seedance2, ...)
|
||||
if self._has_api_key(engine) and self._budget_allows(engine, duration_sec):
|
||||
logger.info(f"영상 라우터: {engine} 선택 (예산 내 유료 엔진)")
|
||||
return engine
|
||||
|
||||
# Final safety net
|
||||
logger.info("영상 라우터: ffmpeg_slides 최종 폴백 선택")
|
||||
return self.fallback_engine
|
||||
|
||||
def generate(self, prompt, engine: str, output_path: str) -> str:
|
||||
"""
|
||||
Generate a video clip using the specified engine.
|
||||
|
||||
prompt: ComposedPrompt object with .text attribute, or plain str.
|
||||
Returns path to output MP4, or '' on failure.
|
||||
"""
|
||||
# Normalise prompt to str
|
||||
if hasattr(prompt, 'text'):
|
||||
prompt_text = prompt.text
|
||||
else:
|
||||
prompt_text = str(prompt)
|
||||
|
||||
logger.info(f"영상 생성 시작: 엔진={engine}, 출력={output_path}")
|
||||
|
||||
if engine == 'kling_free':
|
||||
result = self._generate_kling(prompt_text, output_path)
|
||||
elif engine == 'ffmpeg_slides':
|
||||
result = self._generate_ffmpeg(prompt_text, output_path)
|
||||
else:
|
||||
# veo3, seedance2, runway — V3.1 구현 예정, ffmpeg_slides로 자동 폴백
|
||||
logger.warning(f"{engine} 구현 미완성 — ffmpeg_slides로 자동 폴백")
|
||||
result = self._generate_ffmpeg(prompt_text, output_path)
|
||||
|
||||
if result:
|
||||
# Update cost tracking
|
||||
cfg = self.engine_opts.get(engine, {})
|
||||
cost_per_sec = cfg.get('cost_per_sec', 0)
|
||||
if cost_per_sec > 0:
|
||||
# Estimate 30s clip cost as a rough default
|
||||
self.state['cost_usd'] = round(
|
||||
self.state.get('cost_usd', 0.0) + cost_per_sec * 30, 4
|
||||
)
|
||||
self._save_state()
|
||||
logger.info(f"영상 생성 완료: {result}")
|
||||
else:
|
||||
logger.warning(f"영상 생성 실패: 엔진={engine}")
|
||||
|
||||
return result
|
||||
|
||||
def on_failure(self, engine: str, error: str) -> str:
|
||||
"""Called when engine fails. Returns next available engine."""
|
||||
logger.warning(f"[영상] 엔진 실패: {engine} — {error}")
|
||||
|
||||
priority = self._cfg.get('options', {}).get('smart_router', {}).get(
|
||||
'priority', ['kling_free', 'veo3', 'seedance2', 'ffmpeg_slides']
|
||||
)
|
||||
|
||||
# Find next available engine after the failed one
|
||||
try:
|
||||
idx = priority.index(engine)
|
||||
candidates = priority[idx + 1:]
|
||||
except ValueError:
|
||||
candidates = priority
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate == 'ffmpeg_slides':
|
||||
return 'ffmpeg_slides' # always available
|
||||
engine_opts = self._cfg.get('options', {}).get(candidate, {})
|
||||
api_key_env = engine_opts.get('api_key_env', '')
|
||||
if self._has_api_key({'api_key_env': api_key_env}):
|
||||
logger.info(f"[영상] 다음 엔진으로 전환: {candidate}")
|
||||
return candidate
|
||||
|
||||
logger.warning("[영상] 사용 가능한 엔진 없음 — ffmpeg_slides로 폴백")
|
||||
return 'ffmpeg_slides'
|
||||
|
||||
# ── Engine implementations ────────────────────────────────
|
||||
|
||||
def _generate_kling(self, prompt_text: str, output_path: str) -> str:
|
||||
"""
|
||||
Kling free tier stub implementation.
|
||||
|
||||
The actual Kling API integration is pending (V3.1).
|
||||
For now, log that the call would be made and fall back to ffmpeg_slides.
|
||||
"""
|
||||
api_key = os.getenv('KLING_API_KEY', '')
|
||||
if not api_key:
|
||||
logger.warning("KLING_API_KEY 미설정 — ffmpeg_slides 폴백")
|
||||
return self._generate_ffmpeg(prompt_text, output_path)
|
||||
|
||||
kling_cfg = self.engine_opts.get('kling_free', {})
|
||||
api_url = kling_cfg.get('api_url', 'https://api.klingai.com/v1')
|
||||
|
||||
# Stub: log what would happen, then fall back
|
||||
logger.info(
|
||||
f"[스텁] Kling API 호출 예정: POST {api_url}/videos/text2video "
|
||||
f"(프롬프트: {prompt_text[:60]}...) — 실제 통합 V3.1에서 구현 예정"
|
||||
)
|
||||
logger.info("Kling 스텁 실행 — ffmpeg_slides로 폴백하여 영상 생성")
|
||||
|
||||
# Track credit usage even for stub (as if 1 credit consumed per call)
|
||||
self.state['kling_credits_used'] = self.state.get('kling_credits_used', 0) + 1
|
||||
self._save_state()
|
||||
|
||||
return self._generate_ffmpeg(prompt_text, output_path)
|
||||
|
||||
def _generate_ffmpeg(self, prompt_text: str, output_path: str) -> str:
|
||||
"""
|
||||
Generate a minimal single-scene video using FFmpegSlidesEngine.
|
||||
Accepts a plain text prompt and wraps it into a scene list.
|
||||
"""
|
||||
try:
|
||||
from bots.converters.video_engine import FFmpegSlidesEngine
|
||||
ffmpeg_cfg = self.engine_opts.get('ffmpeg_slides', {})
|
||||
engine = FFmpegSlidesEngine(ffmpeg_cfg)
|
||||
|
||||
# Wrap prompt into minimal scene structure expected by FFmpegSlidesEngine
|
||||
scenes = [
|
||||
{
|
||||
'text': prompt_text[:200], # truncate if very long
|
||||
'type': 'headline',
|
||||
}
|
||||
]
|
||||
return engine.generate(scenes, output_path)
|
||||
except Exception as e:
|
||||
logger.error(f"FFmpegSlidesEngine 실패: {e}")
|
||||
return ''
|
||||
|
||||
|
||||
# ── Module entry point (--test mode) ─────────────────────────
|
||||
|
||||
def _load_engine_config() -> dict:
|
||||
"""Load engine.json from config directory."""
|
||||
config_path = BASE_DIR / 'config' / 'engine.json'
|
||||
try:
|
||||
return json.loads(config_path.read_text(encoding='utf-8'))
|
||||
except Exception as e:
|
||||
logger.error(f"engine.json 로드 실패: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _run_test() -> None:
|
||||
"""Print current router state and selected engine for a 30s clip."""
|
||||
print("=" * 60)
|
||||
print("SmartVideoRouter - 테스트 모드")
|
||||
print("=" * 60)
|
||||
|
||||
config = _load_engine_config()
|
||||
if not config:
|
||||
print("[오류] engine.json 로드 실패")
|
||||
sys.exit(1)
|
||||
|
||||
router = SmartVideoRouter(config)
|
||||
|
||||
print("\n[현재 상태]")
|
||||
state = router._get_state()
|
||||
for k, v in state.items():
|
||||
print(f" {k}: {v}")
|
||||
|
||||
print("\n[엔진 우선순위]")
|
||||
for i, eng in enumerate(router.priority, 1):
|
||||
has_key = router._has_api_key(eng)
|
||||
key_env = router.engine_opts.get(eng, {}).get('api_key_env', '(없음)')
|
||||
print(f" {i}. {eng} - API키={key_env} 설정됨={has_key}")
|
||||
|
||||
print("\n[30초 클립 엔진 선택]")
|
||||
selected = router.select(duration_sec=30, needs_audio=True)
|
||||
print(f" → 선택된 엔진: {selected}")
|
||||
|
||||
cost_spent = state.get('cost_usd', 0.0)
|
||||
cost_limit = router.daily_cost_limit_usd
|
||||
kling_used = state.get('kling_credits_used', 0)
|
||||
kling_limit = router.engine_opts.get('kling_free', {}).get('free_daily_credits', 66)
|
||||
print(f"\n[예산 현황]")
|
||||
print(f" 일일 비용: ${cost_spent:.4f} / ${cost_limit:.2f}")
|
||||
print(f" Kling 크레딧: {kling_used} / {kling_limit} 사용")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if '--test' in sys.argv:
|
||||
_run_test()
|
||||
else:
|
||||
print("사용법: python -m bots.converters.smart_video_router --test")
|
||||
@@ -6,7 +6,6 @@
|
||||
지원 엔진:
|
||||
- FFmpegSlidesEngine: 기존 shorts_converter.py 파이프라인 (슬라이드 + TTS + ffmpeg)
|
||||
- SeedanceEngine: Seedance 2.0 API (AI 영상 생성)
|
||||
- SoraEngine: OpenAI Sora (미지원 → ffmpeg_slides 폴백)
|
||||
- RunwayEngine: Runway Gen-3 API
|
||||
- VeoEngine: Google Veo 3.1 (미지원 → ffmpeg_slides 폴백)
|
||||
"""
|
||||
@@ -23,7 +22,7 @@ from typing import Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
LOG_DIR = BASE_DIR / 'logs'
|
||||
@@ -588,22 +587,6 @@ class SeedanceEngine(VideoEngine):
|
||||
return self._fallback(scenes, output_path, **kwargs)
|
||||
|
||||
|
||||
# ─── SoraEngine ────────────────────────────────────────
|
||||
|
||||
class SoraEngine(VideoEngine):
|
||||
"""
|
||||
OpenAI Sora 영상 생성 엔진.
|
||||
현재 API 공개 접근 불가 — ffmpeg_slides로 폴백.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: dict):
|
||||
self.cfg = cfg
|
||||
|
||||
def generate(self, scenes: list, output_path: str, **kwargs) -> str:
|
||||
logger.warning("Sora API 미지원. ffmpeg_slides로 폴백.")
|
||||
return FFmpegSlidesEngine(self.cfg).generate(scenes, output_path, **kwargs)
|
||||
|
||||
|
||||
# ─── RunwayEngine ──────────────────────────────────────
|
||||
|
||||
class RunwayEngine(VideoEngine):
|
||||
@@ -774,7 +757,6 @@ def get_engine(video_cfg: dict) -> VideoEngine:
|
||||
engine_map = {
|
||||
'ffmpeg_slides': FFmpegSlidesEngine,
|
||||
'seedance': SeedanceEngine,
|
||||
'sora': SoraEngine,
|
||||
'runway': RunwayEngine,
|
||||
'veo': VeoEngine,
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ from pathlib import Path
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
LOG_DIR = BASE_DIR / 'logs'
|
||||
|
||||
@@ -19,7 +19,7 @@ from pathlib import Path
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
LOG_DIR = BASE_DIR / 'logs'
|
||||
|
||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
LOG_DIR = BASE_DIR / 'logs'
|
||||
|
||||
@@ -16,7 +16,7 @@ import requests
|
||||
from dotenv import load_dotenv
|
||||
from requests_oauthlib import OAuth1
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
LOG_DIR = BASE_DIR / 'logs'
|
||||
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
LOG_DIR = BASE_DIR / 'logs'
|
||||
|
||||
@@ -20,7 +20,7 @@ from typing import Any, Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
CONFIG_PATH = BASE_DIR / 'config' / 'engine.json'
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ from pathlib import Path
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
DATA_DIR = BASE_DIR / 'data'
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
CONFIG_DIR = BASE_DIR / 'config'
|
||||
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
|
||||
@@ -18,7 +18,7 @@ from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
# novel/ 폴더 기준으로 BASE_DIR 설정
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
bots/prompt_layer/__init__.py
|
||||
Unified entry point for all prompt composition.
|
||||
|
||||
V3.0 scope: video + search + tts categories
|
||||
V3.1+: expand to all categories
|
||||
"""
|
||||
from .base import ComposedPrompt
|
||||
from .video_prompt import KlingPromptFormatter, VeoPromptFormatter
|
||||
from .search_query import StockSearchQueryComposer
|
||||
|
||||
|
||||
def compose(category: str, input_data: dict, engine: str) -> 'ComposedPrompt':
|
||||
"""
|
||||
Unified entry point for all prompt composition.
|
||||
|
||||
category: 'video' | 'search' | 'tts' | 'image' | 'writing' | 'caption'
|
||||
input_data: category-specific dict
|
||||
engine: target engine name
|
||||
|
||||
V3.0 scope: video + search only
|
||||
V3.1+: expand to all categories
|
||||
"""
|
||||
composer = _get_composer(category, engine)
|
||||
return composer.compose(input_data, engine)
|
||||
|
||||
|
||||
def _get_composer(category: str, engine: str):
|
||||
"""Return appropriate composer for category+engine combination."""
|
||||
if category == 'video':
|
||||
if engine in ('kling_free', 'kling_pro'):
|
||||
return KlingPromptFormatter()
|
||||
else:
|
||||
return VeoPromptFormatter()
|
||||
elif category == 'search':
|
||||
return StockSearchQueryComposer()
|
||||
else:
|
||||
# Fallback: return a passthrough composer for unsupported categories
|
||||
from .base import PassthroughComposer
|
||||
return PassthroughComposer()
|
||||
|
||||
|
||||
__all__ = ['compose', 'ComposedPrompt']
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
bots/prompt_layer/base.py
|
||||
Base types for the prompt layer.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class ComposedPrompt:
|
||||
"""
|
||||
Unified prompt container returned by all composers.
|
||||
|
||||
Fields used varies by engine:
|
||||
- Kling: positive + negative
|
||||
- Veo: positive (structured)
|
||||
- Search: queries list
|
||||
- TTS: processed_text
|
||||
"""
|
||||
positive: str = ''
|
||||
negative: str = ''
|
||||
queries: list[str] = field(default_factory=list)
|
||||
processed_text: str = ''
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.positive or self.queries or self.processed_text)
|
||||
|
||||
|
||||
class BaseComposer:
|
||||
"""Abstract base for all composers."""
|
||||
def compose(self, input_data: dict, engine: str) -> ComposedPrompt:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class PassthroughComposer(BaseComposer):
|
||||
"""Returns input as-is for unsupported categories."""
|
||||
def compose(self, input_data: dict, engine: str) -> ComposedPrompt:
|
||||
return ComposedPrompt(
|
||||
positive=input_data.get('text', ''),
|
||||
metadata={'passthrough': True, 'engine': engine}
|
||||
)
|
||||
@@ -0,0 +1,442 @@
|
||||
"""
|
||||
bots/prompt_layer/korean_preprocessor.py
|
||||
Korean TTS text preprocessing.
|
||||
|
||||
Functions:
|
||||
- preprocess_korean(text): apply pronunciation map + number conversion
|
||||
- insert_pauses(script): insert SSML/marker pauses by sentence type
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# English/acronym → Korean phonetic pronunciation
|
||||
# 200+ entries covering tech, finance, social media, brands, etc.
|
||||
PRONUNCIATION_MAP = {
|
||||
# AI/Tech terms
|
||||
'AI': '에이아이',
|
||||
'API': '에이피아이',
|
||||
'GPT': '지피티',
|
||||
'ChatGPT': '챗지피티',
|
||||
'Claude': '클로드',
|
||||
'GitHub': '깃허브',
|
||||
'OpenAI': '오픈에이아이',
|
||||
'YouTube': '유튜브',
|
||||
'TikTok': '틱톡',
|
||||
'SEO': '에스이오',
|
||||
'SaaS': '사스',
|
||||
'UI': '유아이',
|
||||
'UX': '유엑스',
|
||||
'LLM': '엘엘엠',
|
||||
'NFT': '엔에프티',
|
||||
'DeFi': '디파이',
|
||||
'IoT': '아이오티',
|
||||
'AR': '에이알',
|
||||
'VR': '브이알',
|
||||
'ML': '머신러닝',
|
||||
'NLP': '엔엘피',
|
||||
'DevOps': '데브옵스',
|
||||
'SQL': '에스큐엘',
|
||||
'HTML': '에이치티엠엘',
|
||||
'CSS': '씨에스에스',
|
||||
'JSON': '제이슨',
|
||||
'URL': '유알엘',
|
||||
'HTTP': '에이치티티피',
|
||||
'HTTPS': '에이치티티피에스',
|
||||
'PC': '피씨',
|
||||
'CPU': '씨피유',
|
||||
'GPU': '지피유',
|
||||
'RAM': '램',
|
||||
'SSD': '에스에스디',
|
||||
'USB': '유에스비',
|
||||
'WiFi': '와이파이',
|
||||
'Bluetooth': '블루투스',
|
||||
'iOS': '아이오에스',
|
||||
'Android': '안드로이드',
|
||||
'App': '앱',
|
||||
'IT': '아이티',
|
||||
'ICT': '아이씨티',
|
||||
'SNS': '에스엔에스',
|
||||
'KPI': '케이피아이',
|
||||
'ROI': '알오아이',
|
||||
'B2B': '비투비',
|
||||
'B2C': '비투씨',
|
||||
'MVP': '엠브이피',
|
||||
'OKR': '오케이알',
|
||||
'CTO': '씨티오',
|
||||
'CEO': '씨이오',
|
||||
'CFO': '씨에프오',
|
||||
'HR': '에이치알',
|
||||
'PR': '피알',
|
||||
'IR': '아이알',
|
||||
# Social/Platforms
|
||||
'Instagram': '인스타그램',
|
||||
'Facebook': '페이스북',
|
||||
'Twitter': '트위터',
|
||||
'LinkedIn': '링크드인',
|
||||
'Netflix': '넷플릭스',
|
||||
'Spotify': '스포티파이',
|
||||
'Uber': '우버',
|
||||
'Airbnb': '에어비앤비',
|
||||
'Amazon': '아마존',
|
||||
'Google': '구글',
|
||||
'Apple': '애플',
|
||||
'Microsoft': '마이크로소프트',
|
||||
'Samsung': '삼성',
|
||||
'LG': '엘지',
|
||||
'SK': '에스케이',
|
||||
'KT': '케이티',
|
||||
# Finance
|
||||
'ETF': '이티에프',
|
||||
'IPO': '아이피오',
|
||||
'S&P': '에스앤피',
|
||||
'NASDAQ': '나스닥',
|
||||
'KOSPI': '코스피',
|
||||
'KOSDAQ': '코스닥',
|
||||
'GDP': '지디피',
|
||||
'IMF': '아이엠에프',
|
||||
'ECB': '이씨비',
|
||||
'Fed': '연준',
|
||||
'P/E': '주가수익비율',
|
||||
# Health/Science
|
||||
'DNA': '디엔에이',
|
||||
'RNA': '알엔에이',
|
||||
'BMI': '비엠아이',
|
||||
'COVID': '코비드',
|
||||
'PCR': '피씨알',
|
||||
# Education/Certification
|
||||
'MBA': '엠비에이',
|
||||
'PhD': '박사',
|
||||
'IELTS': '아이엘츠',
|
||||
'TOEIC': '토익',
|
||||
'TOEFL': '토플',
|
||||
# Measurement units
|
||||
'km': '킬로미터',
|
||||
'kg': '킬로그램',
|
||||
'MB': '메가바이트',
|
||||
'GB': '기가바이트',
|
||||
'TB': '테라바이트',
|
||||
'Hz': '헤르츠',
|
||||
'MHz': '메가헤르츠',
|
||||
'GHz': '기가헤르츠',
|
||||
# Media/Entertainment
|
||||
'OTT': '오티티',
|
||||
'VOD': '브이오디',
|
||||
'BGM': '비지엠',
|
||||
'OST': '오에스티',
|
||||
'DJ': '디제이',
|
||||
'MC': '엠씨',
|
||||
'PD': '피디',
|
||||
'CP': '씨피',
|
||||
# Common English words used in Korean context
|
||||
'App Store': '앱 스토어',
|
||||
'Play Store': '플레이 스토어',
|
||||
'ChatBot': '챗봇',
|
||||
'Web3': '웹쓰리',
|
||||
'Metaverse': '메타버스',
|
||||
'Blockchain': '블록체인',
|
||||
'Crypto': '크립토',
|
||||
'Bitcoin': '비트코인',
|
||||
'Ethereum': '이더리움',
|
||||
'Cloud': '클라우드',
|
||||
'Big Data': '빅데이터',
|
||||
'Startup': '스타트업',
|
||||
'Fintech': '핀테크',
|
||||
'Edtech': '에드테크',
|
||||
'Healthtech': '헬스테크',
|
||||
'PropTech': '프롭테크',
|
||||
'LegalTech': '리걸테크',
|
||||
'FOMO': '포모',
|
||||
'YOLO': '욜로',
|
||||
'MZ': '엠제트',
|
||||
# More tech
|
||||
'Python': '파이썬',
|
||||
'JavaScript': '자바스크립트',
|
||||
'TypeScript': '타입스크립트',
|
||||
'React': '리액트',
|
||||
'Node.js': '노드제이에스',
|
||||
'Docker': '도커',
|
||||
'Kubernetes': '쿠버네티스',
|
||||
'AWS': '에이더블유에스',
|
||||
'GCP': '지씨피',
|
||||
'Azure': '애저',
|
||||
'Slack': '슬랙',
|
||||
'Zoom': '줌',
|
||||
'Discord': '디스코드',
|
||||
'Notion': '노션',
|
||||
'Figma': '피그마',
|
||||
'Canva': '캔바',
|
||||
# Business/Strategy
|
||||
'OEM': '오이엠',
|
||||
'ODM': '오디엠',
|
||||
'SCM': '에스씨엠',
|
||||
'ERP': '이알피',
|
||||
'CRM': '씨알엠',
|
||||
# More social media
|
||||
'Reels': '릴스',
|
||||
'Stories': '스토리',
|
||||
'Live': '라이브',
|
||||
'Feed': '피드',
|
||||
'DM': '디엠',
|
||||
'PM': '피엠',
|
||||
'QA': '큐에이',
|
||||
# Content
|
||||
'Blog': '블로그',
|
||||
'Vlog': '브이로그',
|
||||
'Podcast': '팟캐스트',
|
||||
'Newsletter': '뉴스레터',
|
||||
'Shorts': '쇼츠',
|
||||
'Reel': '릴',
|
||||
# Misc
|
||||
'OK': '오케이',
|
||||
'NO': '노',
|
||||
'YES': '예스',
|
||||
'WOW': '와우',
|
||||
'LOL': '엘오엘',
|
||||
'BTW': '그런데',
|
||||
'FYI': '참고로',
|
||||
'ASAP': '최대한 빨리',
|
||||
'FAQ': '자주 묻는 질문',
|
||||
'Q&A': '질의응답',
|
||||
'A/S': '에이에스',
|
||||
'DIY': '디아이와이',
|
||||
'PPT': '피피티',
|
||||
'PDF': '피디에프',
|
||||
'ZIP': '집',
|
||||
# AI/LLM extended
|
||||
'Gemini': '제미나이',
|
||||
'Grok': '그록',
|
||||
'Copilot': '코파일럿',
|
||||
'Perplexity': '퍼플렉시티',
|
||||
'Midjourney': '미드저니',
|
||||
'Stable Diffusion': '스테이블 디퓨전',
|
||||
'DALL-E': '달리',
|
||||
'Sora': '소라',
|
||||
'Kling': '클링',
|
||||
'Runway': '런웨이',
|
||||
# Dev tools / infra
|
||||
'Git': '깃',
|
||||
'Linux': '리눅스',
|
||||
'Ubuntu': '우분투',
|
||||
'Windows': '윈도우',
|
||||
'macOS': '맥오에스',
|
||||
'Terminal': '터미널',
|
||||
'CI/CD': '씨아이씨디',
|
||||
'API Gateway': '에이피아이 게이트웨이',
|
||||
# Finance extended
|
||||
'PER': '주가수익비율',
|
||||
'PBR': '주가순자산비율',
|
||||
'EPS': '주당순이익',
|
||||
'ROE': '자기자본이익률',
|
||||
'CAGR': '연평균성장률',
|
||||
# E-commerce / marketing
|
||||
'CPC': '클릭당비용',
|
||||
'CPM': '천회노출당비용',
|
||||
'CTA': '씨티에이',
|
||||
'CTR': '클릭률',
|
||||
'ROAS': '광고수익률',
|
||||
'LTV': '고객생애가치',
|
||||
}
|
||||
|
||||
# Pause durations in milliseconds by sentence type
|
||||
DYNAMIC_PAUSES = {
|
||||
'hook_after': 500, # ms — impact emphasis after hook
|
||||
'question_after': 400, # thinking time after question
|
||||
'normal_after': 300, # standard sentence end
|
||||
'section_break': 600, # body → closer transition
|
||||
'comma': 150, # comma pause
|
||||
'exclamation': 200, # exclamation mark pause
|
||||
}
|
||||
|
||||
# Number → Korean word conversion rules
|
||||
_NUM_TO_KO = {
|
||||
0: '영', 1: '일', 2: '이', 3: '삼', 4: '사', 5: '오',
|
||||
6: '육', 7: '칠', 8: '팔', 9: '구', 10: '십',
|
||||
100: '백', 1000: '천', 10000: '만',
|
||||
}
|
||||
|
||||
# Counter words for common units (for better number reading)
|
||||
_COUNTER_MAP = {
|
||||
'개': ('개', False), # items
|
||||
'명': ('명', False), # people
|
||||
'번': ('번', False), # times
|
||||
'배': ('배', False), # times/multiples
|
||||
'위': ('위', False), # rank
|
||||
'가지': ('가지', True), # types (use sino-Korean)
|
||||
'초': ('초', False), # seconds
|
||||
'분': ('분', False), # minutes
|
||||
'시간': ('시간', False), # hours
|
||||
'일': ('일', False), # days
|
||||
'월': ('월', False), # months
|
||||
'년': ('년', False), # years
|
||||
'%': ('퍼센트', False), # percent
|
||||
}
|
||||
|
||||
|
||||
def preprocess_korean(text: str) -> str:
|
||||
"""
|
||||
Apply pronunciation map and number conversion to Korean text.
|
||||
|
||||
1. Replace English/acronym terms with Korean phonetics
|
||||
2. Convert Arabic numerals with counter words to Korean
|
||||
|
||||
Returns processed text ready for TTS.
|
||||
"""
|
||||
# Apply pronunciation map (longer strings first to avoid partial replacement)
|
||||
sorted_map = sorted(PRONUNCIATION_MAP.items(), key=lambda x: -len(x[0]))
|
||||
for en, ko in sorted_map:
|
||||
# Word boundary replacement to avoid partial matches
|
||||
text = re.sub(r'(?<![가-힣\w])' + re.escape(en) + r'(?![가-힣\w])', ko, text)
|
||||
|
||||
# Convert numbers
|
||||
text = _convert_numbers(text)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _convert_numbers(text: str) -> str:
|
||||
"""
|
||||
Convert Arabic numerals in Korean context.
|
||||
e.g.: "3가지" → "세 가지", "100%" → "백 퍼센트"
|
||||
"""
|
||||
# Handle percentage
|
||||
text = re.sub(r'(\d+)%', lambda m: _num_to_korean(int(m.group(1))) + ' 퍼센트', text)
|
||||
|
||||
# Handle number + counter word
|
||||
for counter, (ko_counter, use_sino) in _COUNTER_MAP.items():
|
||||
if counter == '%':
|
||||
continue
|
||||
pattern = r'(\d+)\s*' + re.escape(counter)
|
||||
def replace(m, kc=ko_counter):
|
||||
n = int(m.group(1))
|
||||
return _num_to_korean(n) + ' ' + kc
|
||||
text = re.sub(pattern, replace, text)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _num_to_korean(n: int) -> str:
|
||||
"""Convert integer to Korean sino-Korean numeral string."""
|
||||
if n == 0:
|
||||
return '영'
|
||||
if n < 0:
|
||||
return '마이너스 ' + _num_to_korean(-n)
|
||||
|
||||
result = ''
|
||||
if n >= 10000:
|
||||
man = n // 10000
|
||||
result += _num_to_korean(man) + '만'
|
||||
n %= 10000
|
||||
if n >= 1000:
|
||||
cheon = n // 1000
|
||||
result += ('' if cheon == 1 else _num_to_korean(cheon)) + '천'
|
||||
n %= 1000
|
||||
if n >= 100:
|
||||
baek = n // 100
|
||||
result += ('' if baek == 1 else _num_to_korean(baek)) + '백'
|
||||
n %= 100
|
||||
if n >= 10:
|
||||
sip = n // 10
|
||||
result += ('' if sip == 1 else _num_to_korean(sip)) + '십'
|
||||
n %= 10
|
||||
if n > 0:
|
||||
result += _NUM_TO_KO[n]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def insert_pauses(script: dict, engine: str = 'ssml') -> dict:
|
||||
"""
|
||||
Insert pause markers into script by sentence type.
|
||||
|
||||
engine='ssml': insert SSML <break> tags (for ElevenLabs, Google TTS)
|
||||
engine='marker': insert [[PAUSE_Xms]] text markers (for Edge TTS, others)
|
||||
|
||||
Returns modified script dict with pauses inserted.
|
||||
"""
|
||||
result = dict(script)
|
||||
|
||||
hook = script.get('hook', '')
|
||||
body = script.get('body', [])
|
||||
closer = script.get('closer', '')
|
||||
|
||||
# Add pause after hook
|
||||
if hook:
|
||||
pause_ms = DYNAMIC_PAUSES['hook_after']
|
||||
result['hook'] = hook + _pause_marker(pause_ms, engine)
|
||||
|
||||
# Add pauses within body sentences
|
||||
processed_body = []
|
||||
for i, sentence in enumerate(body):
|
||||
processed = _add_inline_pauses(sentence, engine)
|
||||
# Add section break before closer transition
|
||||
if i == len(body) - 1:
|
||||
processed += _pause_marker(DYNAMIC_PAUSES['section_break'], engine)
|
||||
else:
|
||||
processed += _pause_marker(DYNAMIC_PAUSES['normal_after'], engine)
|
||||
processed_body.append(processed)
|
||||
result['body'] = processed_body
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _add_inline_pauses(sentence: str, engine: str) -> str:
|
||||
"""Add pauses at commas and after exclamation marks."""
|
||||
# Comma pauses
|
||||
sentence = re.sub(
|
||||
r',\s*',
|
||||
',' + _pause_marker(DYNAMIC_PAUSES['comma'], engine),
|
||||
sentence
|
||||
)
|
||||
# Question mark pauses
|
||||
sentence = re.sub(
|
||||
r'\?\s*',
|
||||
'?' + _pause_marker(DYNAMIC_PAUSES['question_after'], engine),
|
||||
sentence
|
||||
)
|
||||
# Exclamation pauses
|
||||
sentence = re.sub(
|
||||
r'!\s*',
|
||||
'!' + _pause_marker(DYNAMIC_PAUSES['exclamation'], engine),
|
||||
sentence
|
||||
)
|
||||
return sentence
|
||||
|
||||
|
||||
def _pause_marker(ms: int, engine: str) -> str:
|
||||
"""Generate engine-appropriate pause marker."""
|
||||
if engine == 'ssml':
|
||||
return f'<break time="{ms}ms"/>'
|
||||
else:
|
||||
return f' [[PAUSE_{ms}ms]] '
|
||||
|
||||
|
||||
# ── Standalone test ──────────────────────────────────────────────
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
if '--test' in sys.argv:
|
||||
print("=== Korean Preprocessor Test ===")
|
||||
test_texts = [
|
||||
"AI와 ChatGPT가 SEO를 바꾸고 있어요",
|
||||
"3가지 방법으로 100%의 수익을 낼 수 있습니다",
|
||||
"YouTube와 TikTok에서 SNS 마케팅하기",
|
||||
"GPT API를 사용한 SaaS 창업",
|
||||
]
|
||||
for text in test_texts:
|
||||
result = preprocess_korean(text)
|
||||
print(f"원문: {text}")
|
||||
print(f"처리: {result}")
|
||||
print()
|
||||
|
||||
# Test pause insertion
|
||||
test_script = {
|
||||
'hook': '이거 모르면 손해입니다!',
|
||||
'body': ['첫 번째, AI를 활용하면 10배 빠릅니다.', '두 번째, 자동화가 핵심입니다.'],
|
||||
'closer': '지금 바로 시작하세요.'
|
||||
}
|
||||
processed = insert_pauses(test_script, engine='marker')
|
||||
print("=== Pause Insertion Test ===")
|
||||
for k, v in processed.items():
|
||||
print(f"{k}: {v}")
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
bots/prompt_layer/prompt_tracker.py
|
||||
SQLite-based prompt logging infrastructure.
|
||||
|
||||
V3.0: Log every prompt to SQLite. No auto-improvement yet.
|
||||
V3.1: Analyze logs → extract patterns → auto-improve.
|
||||
|
||||
Schema:
|
||||
prompt_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL, -- 'video' | 'search' | 'tts' | 'writing' | ...
|
||||
engine TEXT NOT NULL, -- target engine name
|
||||
prompt TEXT NOT NULL, -- full prompt text
|
||||
result_quality REAL DEFAULT -1, -- 0.0-1.0, -1 = not evaluated
|
||||
user_edited INTEGER DEFAULT 0, -- 1 if user manually edited the result
|
||||
created_at TEXT NOT NULL -- ISO 8601 timestamp
|
||||
)
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
DB_PATH = BASE_DIR / 'data' / 'prompt_log.db'
|
||||
|
||||
# DDL for creating the table
|
||||
_CREATE_TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS prompt_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL,
|
||||
engine TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
result_quality REAL NOT NULL DEFAULT -1,
|
||||
user_edited INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_category ON prompt_log (category);
|
||||
CREATE INDEX IF NOT EXISTS idx_engine ON prompt_log (engine);
|
||||
CREATE INDEX IF NOT EXISTS idx_created ON prompt_log (created_at);
|
||||
"""
|
||||
|
||||
|
||||
class PromptTracker:
|
||||
"""
|
||||
Logs prompts to SQLite for future analysis and auto-improvement.
|
||||
|
||||
V3.0: Logging only.
|
||||
V3.1: Will add get_engine_preferences() and suggest_improvement().
|
||||
|
||||
Usage:
|
||||
tracker = PromptTracker()
|
||||
tracker.log('video', 'kling_free', prompt_text)
|
||||
tracker.log('search', 'pexels', query_text, result_quality=0.8)
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path = DB_PATH):
|
||||
self._db_path = db_path
|
||||
self._initialized = False
|
||||
|
||||
def _ensure_db(self) -> None:
|
||||
"""Create database and tables if they don't exist."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
with sqlite3.connect(str(self._db_path)) as conn:
|
||||
for statement in _CREATE_TABLE_SQL.strip().split(';'):
|
||||
stmt = statement.strip()
|
||||
if stmt:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
self._initialized = True
|
||||
logger.debug(f'[트래커] DB 초기화: {self._db_path}')
|
||||
except sqlite3.Error as e:
|
||||
logger.error(f'[트래커] DB 초기화 실패: {e}')
|
||||
|
||||
def log(
|
||||
self,
|
||||
category: str,
|
||||
engine: str,
|
||||
prompt: str,
|
||||
result_quality: float = -1.0,
|
||||
user_edited: bool = False,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Log a prompt to SQLite.
|
||||
|
||||
Args:
|
||||
category: Prompt category ('video', 'search', 'tts', 'writing', etc.)
|
||||
engine: Target engine name ('kling_free', 'pexels', 'elevenlabs', etc.)
|
||||
prompt: Full prompt text
|
||||
result_quality: Quality score 0.0-1.0, or -1 if not evaluated
|
||||
user_edited: True if user manually modified the AI output
|
||||
|
||||
Returns: Row ID of inserted record, or None on error
|
||||
"""
|
||||
self._ensure_db()
|
||||
|
||||
if not category or not engine or not prompt:
|
||||
logger.warning('[트래커] 필수 파라미터 누락 — 로깅 건너뜀')
|
||||
return None
|
||||
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
try:
|
||||
with sqlite3.connect(str(self._db_path)) as conn:
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO prompt_log
|
||||
(category, engine, prompt, result_quality, user_edited, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(category, engine, prompt, float(result_quality), int(user_edited), created_at)
|
||||
)
|
||||
conn.commit()
|
||||
row_id = cursor.lastrowid
|
||||
logger.debug(f'[트래커] 로그 저장: id={row_id}, category={category}, engine={engine}')
|
||||
return row_id
|
||||
except sqlite3.Error as e:
|
||||
logger.error(f'[트래커] 로그 저장 실패: {e}')
|
||||
return None
|
||||
|
||||
def get_recent(
|
||||
self,
|
||||
category: Optional[str] = None,
|
||||
engine: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Retrieve recent log entries.
|
||||
|
||||
Args:
|
||||
category: Filter by category (None = all)
|
||||
engine: Filter by engine (None = all)
|
||||
limit: Max records to return
|
||||
|
||||
Returns: List of dicts with log fields
|
||||
"""
|
||||
self._ensure_db()
|
||||
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if category:
|
||||
conditions.append('category = ?')
|
||||
params.append(category)
|
||||
if engine:
|
||||
conditions.append('engine = ?')
|
||||
params.append(engine)
|
||||
|
||||
where = 'WHERE ' + ' AND '.join(conditions) if conditions else ''
|
||||
params.append(limit)
|
||||
|
||||
try:
|
||||
with sqlite3.connect(str(self._db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(
|
||||
f'SELECT * FROM prompt_log {where} ORDER BY created_at DESC LIMIT ?',
|
||||
params
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
except sqlite3.Error as e:
|
||||
logger.error(f'[트래커] 조회 실패: {e}')
|
||||
return []
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""
|
||||
Return summary statistics.
|
||||
|
||||
Returns: {
|
||||
'total': int,
|
||||
'by_category': {category: count},
|
||||
'by_engine': {engine: count},
|
||||
'avg_quality': float,
|
||||
'user_edited_count': int,
|
||||
}
|
||||
"""
|
||||
self._ensure_db()
|
||||
|
||||
try:
|
||||
with sqlite3.connect(str(self._db_path)) as conn:
|
||||
total = conn.execute('SELECT COUNT(*) FROM prompt_log').fetchone()[0]
|
||||
|
||||
by_cat = dict(conn.execute(
|
||||
'SELECT category, COUNT(*) FROM prompt_log GROUP BY category'
|
||||
).fetchall())
|
||||
|
||||
by_eng = dict(conn.execute(
|
||||
'SELECT engine, COUNT(*) FROM prompt_log GROUP BY engine'
|
||||
).fetchall())
|
||||
|
||||
avg_q = conn.execute(
|
||||
'SELECT AVG(result_quality) FROM prompt_log WHERE result_quality >= 0'
|
||||
).fetchone()[0]
|
||||
|
||||
edited = conn.execute(
|
||||
'SELECT COUNT(*) FROM prompt_log WHERE user_edited = 1'
|
||||
).fetchone()[0]
|
||||
|
||||
return {
|
||||
'total': total,
|
||||
'by_category': by_cat,
|
||||
'by_engine': by_eng,
|
||||
'avg_quality': round(avg_q, 3) if avg_q is not None else None,
|
||||
'user_edited_count': edited,
|
||||
}
|
||||
except sqlite3.Error as e:
|
||||
logger.error(f'[트래커] 통계 조회 실패: {e}')
|
||||
return {}
|
||||
|
||||
# V3.1 stubs (not implemented yet)
|
||||
|
||||
def get_engine_preferences(self, engine: str) -> dict:
|
||||
"""
|
||||
V3.1: Analyze logs to extract what works best for an engine.
|
||||
Returns: {} in V3.0 (not implemented)
|
||||
"""
|
||||
logger.debug('[트래커] get_engine_preferences — V3.1에서 구현 예정')
|
||||
return {}
|
||||
|
||||
def suggest_improvement(self, category: str, engine: str) -> str:
|
||||
"""
|
||||
V3.1: Suggest prompt improvements based on historical data.
|
||||
Returns: '' in V3.0 (not implemented)
|
||||
"""
|
||||
logger.debug('[트래커] suggest_improvement — V3.1에서 구현 예정')
|
||||
return ''
|
||||
|
||||
|
||||
# ── Standalone test ──────────────────────────────────────────────
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
if '--test' in sys.argv:
|
||||
print("=== Prompt Tracker Test ===")
|
||||
|
||||
# Use temp DB for testing
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
test_db = Path(tmp) / 'test_prompt_log.db'
|
||||
tracker = PromptTracker(db_path=test_db)
|
||||
|
||||
# Log some prompts
|
||||
id1 = tracker.log('video', 'kling_free', 'A cinematic shot of technology', result_quality=0.8)
|
||||
id2 = tracker.log('search', 'pexels', 'artificial intelligence screen', result_quality=0.9)
|
||||
id3 = tracker.log('tts', 'edge_tts', 'AI와 ChatGPT가 SEO를 바꾸고 있어요', user_edited=True)
|
||||
id4 = tracker.log('video', 'kling_free', 'Korean business meeting professional')
|
||||
|
||||
print(f"Logged 4 entries (IDs: {id1}, {id2}, {id3}, {id4})")
|
||||
|
||||
# Get stats
|
||||
stats = tracker.get_stats()
|
||||
print(f"Stats: total={stats['total']}, avg_quality={stats['avg_quality']}")
|
||||
print(f"By category: {stats['by_category']}")
|
||||
print(f"User edited: {stats['user_edited_count']}")
|
||||
|
||||
# Get recent
|
||||
recent = tracker.get_recent(category='video', limit=10)
|
||||
print(f"Recent video prompts: {len(recent)} entries")
|
||||
|
||||
# Test V3.1 stubs
|
||||
prefs = tracker.get_engine_preferences('kling_free')
|
||||
print(f"V3.1 stub (engine_preferences): {prefs}")
|
||||
|
||||
print("All tests passed!")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
bots/prompt_layer/search_query.py
|
||||
Compose stock video/image search queries from Korean concepts.
|
||||
"""
|
||||
from .base import BaseComposer, ComposedPrompt
|
||||
from .visual_vocabulary import CONCEPT_TO_VISUAL, VISUAL_STYLE_MODIFIERS
|
||||
import re
|
||||
|
||||
|
||||
class StockSearchQueryComposer(BaseComposer):
|
||||
"""
|
||||
Korean concept -> English visual search terms.
|
||||
Used to search Pexels/Pixabay/Unsplash for stock footage.
|
||||
"""
|
||||
|
||||
def compose(self, input_data: dict, engine: str = 'pexels') -> ComposedPrompt:
|
||||
"""
|
||||
input_data: {
|
||||
'sentence': str, # Korean sentence to find visuals for
|
||||
'platform': str, # 'pexels' | 'pixabay' | 'kling' | 'veo'
|
||||
'count': int, # number of search queries to return (default 3)
|
||||
}
|
||||
Returns ComposedPrompt with queries list
|
||||
"""
|
||||
sentence = input_data.get('sentence', '')
|
||||
count = input_data.get('count', 3)
|
||||
|
||||
queries = self._sentence_to_queries(sentence, count)
|
||||
|
||||
return ComposedPrompt(
|
||||
queries=queries,
|
||||
metadata={'sentence': sentence, 'engine': engine}
|
||||
)
|
||||
|
||||
def _sentence_to_queries(self, sentence: str, count: int) -> list[str]:
|
||||
"""Extract Korean concepts from sentence and map to visual search terms."""
|
||||
# Find matching concepts from vocabulary
|
||||
matched_visuals = []
|
||||
for concept, visuals in CONCEPT_TO_VISUAL.items():
|
||||
if concept in sentence:
|
||||
matched_visuals.extend(visuals)
|
||||
|
||||
# If no matches, use generic professional stock footage terms
|
||||
if not matched_visuals:
|
||||
matched_visuals = ['professional business', 'modern lifestyle', 'technology future']
|
||||
|
||||
# Return up to count unique queries
|
||||
seen = set()
|
||||
unique = []
|
||||
for v in matched_visuals:
|
||||
if v not in seen:
|
||||
seen.add(v)
|
||||
unique.append(v)
|
||||
|
||||
return unique[:count]
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
bots/prompt_layer/video_prompt.py
|
||||
Format prompts for video generation engines (Kling, Veo).
|
||||
"""
|
||||
from .base import BaseComposer, ComposedPrompt
|
||||
from .visual_vocabulary import VISUAL_STYLE_MODIFIERS, NEGATIVE_TERMS
|
||||
|
||||
|
||||
class KlingPromptFormatter(BaseComposer):
|
||||
"""
|
||||
Format prompts for Kling AI video generation.
|
||||
Kling works best with: scene description + movement + mood + negative prompt.
|
||||
"""
|
||||
|
||||
def compose(self, input_data: dict, engine: str = 'kling_free') -> ComposedPrompt:
|
||||
"""
|
||||
input_data: {
|
||||
'scenes': list[dict], # [{text, type, image_prompt}, ...]
|
||||
'corner': str, # content corner/category
|
||||
'duration': float, # target duration in seconds
|
||||
}
|
||||
"""
|
||||
scenes = input_data.get('scenes', [])
|
||||
corner = input_data.get('corner', '')
|
||||
|
||||
# Build positive prompt from scenes
|
||||
scene_texts = []
|
||||
for scene in scenes:
|
||||
prompt = scene.get('image_prompt') or scene.get('text', '')
|
||||
if prompt:
|
||||
scene_texts.append(self._enhance_for_kling(prompt, corner))
|
||||
|
||||
positive = '. '.join(scene_texts[:3]) # Max 3 scenes per prompt
|
||||
if not positive:
|
||||
positive = f'cinematic short video about {corner or "technology"}'
|
||||
|
||||
# Kling negative prompt
|
||||
negative = ', '.join(NEGATIVE_TERMS + ['text overlay', 'subtitles', 'watermark'])
|
||||
|
||||
# Add beat markers for Kling
|
||||
positive = f'{positive}. Camera: smooth movement, vertical 9:16 format. Style: cinematic, vibrant.'
|
||||
|
||||
return ComposedPrompt(
|
||||
positive=positive,
|
||||
negative=negative,
|
||||
metadata={'engine': engine, 'corner': corner}
|
||||
)
|
||||
|
||||
def _enhance_for_kling(self, text: str, corner: str) -> str:
|
||||
"""Add cinematic enhancement to prompt."""
|
||||
modifiers = ', '.join(VISUAL_STYLE_MODIFIERS[:3])
|
||||
return f'{text}, {modifiers}'
|
||||
|
||||
|
||||
class VeoPromptFormatter(BaseComposer):
|
||||
"""
|
||||
Format prompts for Google Veo video generation.
|
||||
Veo works best with structured ingredient list format.
|
||||
"""
|
||||
|
||||
def compose(self, input_data: dict, engine: str = 'veo3') -> ComposedPrompt:
|
||||
"""
|
||||
input_data: same as KlingPromptFormatter
|
||||
"""
|
||||
scenes = input_data.get('scenes', [])
|
||||
corner = input_data.get('corner', '')
|
||||
|
||||
scene_texts = [
|
||||
scene.get('image_prompt') or scene.get('text', '')
|
||||
for scene in scenes if scene.get('image_prompt') or scene.get('text')
|
||||
]
|
||||
|
||||
# Veo structured format: Subject + Action + Setting + Style
|
||||
subject = scene_texts[0] if scene_texts else f'{corner or "technology"} concept'
|
||||
positive = (
|
||||
f'Subject: {subject}. '
|
||||
f'Format: vertical 9:16 portrait video. '
|
||||
f'Style: cinematic, {", ".join(VISUAL_STYLE_MODIFIERS[:2])}. '
|
||||
f'Camera: smooth pan or zoom. Duration: short clip.'
|
||||
)
|
||||
|
||||
return ComposedPrompt(
|
||||
positive=positive,
|
||||
metadata={'engine': engine, 'corner': corner, 'format': 'veo_structured'}
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
bots/prompt_layer/visual_vocabulary.py
|
||||
Shared Korean -> English visual concept dictionary.
|
||||
Used by search_query.py and video_prompt.py for concept mapping.
|
||||
"""
|
||||
|
||||
CONCEPT_TO_VISUAL = {
|
||||
# Technology
|
||||
'AI': ['artificial intelligence screen', 'digital interface', 'neural network visualization'],
|
||||
'인공지능': ['robot brain', 'digital mind', 'AI hologram'],
|
||||
'자동화': ['gears mechanism', 'conveyor belt', 'robot arm factory'],
|
||||
'코딩': ['computer code screen', 'programmer keyboard', 'dark terminal code'],
|
||||
'데이터': ['data visualization', 'bar chart analytics', 'network nodes'],
|
||||
'알고리즘': ['flowchart diagram', 'binary code', 'decision tree'],
|
||||
'앱': ['smartphone screen', 'mobile app interface', 'app store'],
|
||||
'소프트웨어': ['software development', 'code editor', 'programming laptop'],
|
||||
# Finance/Money
|
||||
'돈': ['money cash bills', 'coins pile', 'dollar bills'],
|
||||
'수익': ['profit growth chart', 'rising arrow money', 'income cash'],
|
||||
'투자': ['stock market chart', 'investment portfolio', 'financial growth'],
|
||||
'절약': ['piggy bank savings', 'money jar coins', 'budget planning'],
|
||||
'부자': ['luxury lifestyle', 'wealthy business person', 'success achievement'],
|
||||
'무료': ['gift present box', 'unlocked padlock', 'free tag label'],
|
||||
'할인': ['sale discount tag', 'percent off sign', 'price reduction'],
|
||||
# Business
|
||||
'비즈니스': ['business meeting', 'office workspace', 'professional handshake'],
|
||||
'창업': ['startup launch rocket', 'entrepreneur office', 'business idea lightbulb'],
|
||||
'마케팅': ['marketing strategy board', 'social media icons', 'advertising billboard'],
|
||||
'브랜드': ['brand logo design', 'brand identity', 'premium label'],
|
||||
'고객': ['customer service smile', 'client meeting', 'happy customer'],
|
||||
'성공': ['success achievement trophy', 'winner podium', 'goal celebration'],
|
||||
'실패': ['failure mistake frustrated', 'broken plan', 'problem obstacle'],
|
||||
# Health/Lifestyle
|
||||
'건강': ['healthy lifestyle', 'fitness exercise', 'fresh vegetables'],
|
||||
'다이어트': ['diet food salad', 'weight loss scale', 'healthy eating'],
|
||||
'운동': ['gym workout exercise', 'running sport', 'fitness training'],
|
||||
'수면': ['peaceful sleep bedroom', 'sleeping person night', 'rest relaxation'],
|
||||
'스트레스': ['stress anxiety person', 'overwhelmed work', 'headache pressure'],
|
||||
'행복': ['happy smiling person', 'joy celebration', 'positive energy'],
|
||||
# Education
|
||||
'공부': ['studying books desk', 'student learning', 'open textbook'],
|
||||
'독서': ['reading book cozy', 'bookshelf library', 'person reading'],
|
||||
'교육': ['classroom teaching', 'education school', 'learning knowledge'],
|
||||
'자격증': ['certificate diploma award', 'achievement credential', 'professional certification'],
|
||||
# Social/Communication
|
||||
'소통': ['communication talking', 'conversation speech bubble', 'people talking'],
|
||||
'관계': ['relationship people together', 'friendship bond', 'social connection'],
|
||||
'가족': ['family together happy', 'family portrait', 'home family'],
|
||||
'친구': ['friends together laughing', 'friendship bond', 'social gathering'],
|
||||
# Environment/Nature
|
||||
'자연': ['nature landscape scenic', 'green forest trees', 'outdoor beauty'],
|
||||
'환경': ['environment ecology', 'green earth planet', 'sustainability'],
|
||||
'도시': ['city skyline urban', 'modern architecture', 'downtown cityscape'],
|
||||
'여행': ['travel adventure journey', 'wanderlust explore', 'tourism destination'],
|
||||
# Time/Productivity
|
||||
'시간': ['clock time management', 'hourglass countdown', 'calendar schedule'],
|
||||
'생산성': ['productivity work desk', 'efficient workflow', 'organized workspace'],
|
||||
'습관': ['habit routine daily', 'calendar habit tracker', 'consistent practice'],
|
||||
'목표': ['goal target arrow', 'achievement milestone', 'success roadmap'],
|
||||
# Food
|
||||
'음식': ['food meal delicious', 'restaurant dining', 'cooking kitchen'],
|
||||
'커피': ['coffee cup cafe', 'espresso morning', 'coffee shop cozy'],
|
||||
'요리': ['cooking chef kitchen', 'recipe preparation', 'homemade food'],
|
||||
# Digital/Social Media
|
||||
'유튜브': ['youtube play button', 'video content creator', 'streaming platform'],
|
||||
'틱톡': ['social media video', 'short video content', 'viral content'],
|
||||
'인스타그램': ['instagram photo aesthetic', 'social media post', 'influencer lifestyle'],
|
||||
'콘텐츠': ['content creation studio', 'digital content', 'creative media'],
|
||||
# Generic actions
|
||||
'시작': ['starting launch beginning', 'new start fresh', 'launch rocket'],
|
||||
'변화': ['change transformation', 'before after contrast', 'evolution progress'],
|
||||
'성장': ['growth plant sprouting', 'growth chart rising', 'development progress'],
|
||||
'문제': ['problem solving puzzle', 'challenge obstacle', 'issue question mark'],
|
||||
'해결': ['solution lightbulb', 'problem solved checkmark', 'resolution answer'],
|
||||
'비교': ['comparison side by side', 'versus contrast', 'pros cons balance'],
|
||||
'순위': ['ranking top list', 'leaderboard winners', 'chart comparison'],
|
||||
'방법': ['how-to guide steps', 'tutorial instruction', 'method process'],
|
||||
'팁': ['tips tricks advice', 'helpful hints', 'pro tip star'],
|
||||
'비밀': ['secret reveal hidden', 'mystery unlock', 'insider knowledge'],
|
||||
'진실': ['truth reveal facts', 'reality check', 'honest disclosure'],
|
||||
'놀라운': ['surprising amazing wow', 'unexpected revelation', 'shocking discovery'],
|
||||
# Numbers/Stats
|
||||
'1위': ['number one winner', 'first place gold', 'top ranked best'],
|
||||
'100%': ['one hundred percent complete', 'full capacity', 'perfect score'],
|
||||
# Korean culture
|
||||
'한국': ['korea seoul cityscape', 'korean culture', 'hanbok traditional'],
|
||||
'직장': ['office workplace corporate', 'work desk professional', 'business office'],
|
||||
'취업': ['job interview hiring', 'employment opportunity', 'career success'],
|
||||
'부동산': ['real estate property', 'house home investment', 'property market'],
|
||||
# Abstract concepts
|
||||
'가능성': ['possibility open door', 'opportunity horizon', 'potential unlimited'],
|
||||
'미래': ['future technology vision', 'futuristic landscape', 'innovation tomorrow'],
|
||||
'트렌드': ['trend arrow upward', 'trending popular', 'hot topic social'],
|
||||
}
|
||||
|
||||
# Quality/style modifiers to append to video/image prompts
|
||||
VISUAL_STYLE_MODIFIERS = [
|
||||
'cinematic',
|
||||
'4k',
|
||||
'professional',
|
||||
'high quality',
|
||||
'vibrant colors',
|
||||
'sharp focus',
|
||||
'natural lighting',
|
||||
'smooth motion',
|
||||
]
|
||||
|
||||
# Terms to avoid in video generation prompts
|
||||
NEGATIVE_TERMS = [
|
||||
'blurry',
|
||||
'low quality',
|
||||
'watermark',
|
||||
'text overlay',
|
||||
'distorted',
|
||||
'pixelated',
|
||||
'grainy',
|
||||
'overexposed',
|
||||
'underexposed',
|
||||
'shaky camera',
|
||||
]
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
|
||||
if '--test' in sys.argv:
|
||||
print('=== visual_vocabulary 테스트 시작 ===')
|
||||
print(f'총 개념 수: {len(CONCEPT_TO_VISUAL)}')
|
||||
print(f'스타일 수식어 수: {len(VISUAL_STYLE_MODIFIERS)}')
|
||||
print(f'네거티브 용어 수: {len(NEGATIVE_TERMS)}')
|
||||
print()
|
||||
|
||||
# Test a few lookups
|
||||
test_concepts = ['AI', '미래', '성공', '건강', '코딩']
|
||||
for concept in test_concepts:
|
||||
visuals = CONCEPT_TO_VISUAL.get(concept, [])
|
||||
print(f' [{concept}] -> {visuals}')
|
||||
|
||||
print()
|
||||
print(f'스타일 수식어: {VISUAL_STYLE_MODIFIERS}')
|
||||
print(f'네거티브 용어: {NEGATIVE_TERMS}')
|
||||
print()
|
||||
print('=== 테스트 완료 ===')
|
||||
else:
|
||||
print('사용법: python -m bots.prompt_layer.visual_vocabulary --test')
|
||||
+20
-1
@@ -25,7 +25,7 @@ from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
CONFIG_DIR = BASE_DIR / 'config'
|
||||
@@ -63,6 +63,7 @@ def load_config(filename: str) -> dict:
|
||||
|
||||
def get_google_credentials() -> Credentials:
|
||||
creds = None
|
||||
# 1) token.json 파일 우선
|
||||
if TOKEN_PATH.exists():
|
||||
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
|
||||
if not creds or not creds.valid:
|
||||
@@ -70,6 +71,24 @@ def get_google_credentials() -> Credentials:
|
||||
creds.refresh(Request())
|
||||
with open(TOKEN_PATH, 'w') as f:
|
||||
f.write(creds.to_json())
|
||||
# 2) .env의 GOOGLE_REFRESH_TOKEN으로 직접 생성 (Docker 환경 대응)
|
||||
if not creds or not creds.valid:
|
||||
refresh_token = os.getenv('GOOGLE_REFRESH_TOKEN', '')
|
||||
client_id = os.getenv('GOOGLE_CLIENT_ID', '')
|
||||
client_secret = os.getenv('GOOGLE_CLIENT_SECRET', '')
|
||||
if refresh_token and client_id and client_secret:
|
||||
creds = Credentials(
|
||||
token=None,
|
||||
refresh_token=refresh_token,
|
||||
token_uri='https://oauth2.googleapis.com/token',
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
scopes=SCOPES,
|
||||
)
|
||||
creds.refresh(Request())
|
||||
with open(TOKEN_PATH, 'w') as f:
|
||||
f.write(creds.to_json())
|
||||
logger.info("Google 인증 성공 (.env refresh token)")
|
||||
if not creds or not creds.valid:
|
||||
raise RuntimeError("Google 인증 실패. scripts/get_token.py 를 먼저 실행하세요.")
|
||||
return creds
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
bots/quality
|
||||
Quality signal computation for shorts content.
|
||||
|
||||
V3.0 signals:
|
||||
- motion_variation_score
|
||||
- script_diversity_score
|
||||
- tts_cost_efficiency
|
||||
|
||||
V3.1+ additions:
|
||||
- semantic_visual_score
|
||||
- caption_overlap_score
|
||||
- pacing_variation_score
|
||||
"""
|
||||
from .micro_signals import compute_signal, SIGNALS_V1
|
||||
|
||||
__all__ = ['compute_signal', 'SIGNALS_V1']
|
||||
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
bots/quality/micro_signals.py
|
||||
Micro-failure quality signals for shorts content.
|
||||
|
||||
V3.0 scope: 3 signals
|
||||
- motion_variation_score: detects repetitive motion patterns
|
||||
- script_diversity_score: detects structural overlap with recent scripts
|
||||
- tts_cost_efficiency: monitors TTS credit usage
|
||||
|
||||
Each signal returns a float 0.0-1.0 where:
|
||||
- 1.0 = perfect / no issue
|
||||
- 0.0 = critical problem
|
||||
- threshold = action trigger point
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Callable, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SIGNALS_V1 = {
|
||||
'motion_variation_score': {
|
||||
'description': 'Consecutive clips using same motion pattern',
|
||||
'threshold': 0.6,
|
||||
'action': 'auto_fix', # pick different pattern automatically
|
||||
'higher_is_better': True,
|
||||
},
|
||||
'script_diversity_score': {
|
||||
'description': 'Script structure overlap with last 7 days',
|
||||
'threshold': 0.5,
|
||||
'action': 'regenerate', # request different structure from LLM
|
||||
'higher_is_better': True,
|
||||
},
|
||||
'tts_cost_efficiency': {
|
||||
'description': 'TTS credit usage vs monthly limit',
|
||||
'threshold': 0.8,
|
||||
'action': 'switch_engine', # downgrade to local TTS
|
||||
'higher_is_better': False, # lower usage = better
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def compute_signal(signal_name: str, **kwargs) -> float:
|
||||
"""
|
||||
Compute a quality signal value.
|
||||
|
||||
Args:
|
||||
signal_name: One of SIGNALS_V1 keys
|
||||
**kwargs: Signal-specific inputs (see individual compute functions)
|
||||
|
||||
Returns: float 0.0-1.0
|
||||
|
||||
Raises: ValueError if signal_name unknown
|
||||
"""
|
||||
if signal_name not in SIGNALS_V1:
|
||||
raise ValueError(f'Unknown signal: {signal_name}. Available: {list(SIGNALS_V1.keys())}')
|
||||
|
||||
compute_fns = {
|
||||
'motion_variation_score': _compute_motion_variation,
|
||||
'script_diversity_score': _compute_script_diversity,
|
||||
'tts_cost_efficiency': _compute_tts_cost_efficiency,
|
||||
}
|
||||
|
||||
fn = compute_fns[signal_name]
|
||||
try:
|
||||
value = fn(**kwargs)
|
||||
logger.debug(f'[품질] {signal_name} = {value:.3f}')
|
||||
return value
|
||||
except Exception as e:
|
||||
logger.warning(f'[품질] 신호 계산 실패 ({signal_name}): {e}')
|
||||
return 1.0 # Neutral value on error (don't trigger action)
|
||||
|
||||
|
||||
def check_and_act(signal_name: str, value: float) -> dict:
|
||||
"""
|
||||
Check if signal value crosses threshold and return action.
|
||||
|
||||
Returns: {
|
||||
'triggered': bool,
|
||||
'action': str or None,
|
||||
'value': float,
|
||||
'threshold': float,
|
||||
}
|
||||
"""
|
||||
if signal_name not in SIGNALS_V1:
|
||||
return {'triggered': False, 'action': None, 'value': value, 'threshold': 0}
|
||||
|
||||
config = SIGNALS_V1[signal_name]
|
||||
threshold = config['threshold']
|
||||
higher_is_better = config.get('higher_is_better', True)
|
||||
|
||||
if higher_is_better:
|
||||
triggered = value < threshold
|
||||
else:
|
||||
triggered = value > threshold
|
||||
|
||||
return {
|
||||
'triggered': triggered,
|
||||
'action': config['action'] if triggered else None,
|
||||
'value': value,
|
||||
'threshold': threshold,
|
||||
}
|
||||
|
||||
|
||||
def _compute_motion_variation(clips: list, **kwargs) -> float:
|
||||
"""
|
||||
Compute motion variation score.
|
||||
|
||||
Args:
|
||||
clips: list of dicts with 'pattern' key, e.g. [{'pattern': 'ken_burns_in'}, ...]
|
||||
|
||||
Returns: 0.0-1.0 diversity score
|
||||
"""
|
||||
if not clips or len(clips) < 2:
|
||||
return 1.0
|
||||
|
||||
patterns = [c.get('pattern', '') for c in clips if c.get('pattern')]
|
||||
if not patterns:
|
||||
return 1.0
|
||||
|
||||
# Count consecutive same-pattern pairs
|
||||
consecutive_same = sum(
|
||||
1 for i in range(len(patterns) - 1)
|
||||
if patterns[i] == patterns[i+1]
|
||||
)
|
||||
|
||||
# Unique patterns ratio
|
||||
unique_ratio = len(set(patterns)) / len(patterns)
|
||||
consecutive_penalty = consecutive_same / max(len(patterns) - 1, 1)
|
||||
|
||||
score = unique_ratio * (1 - consecutive_penalty)
|
||||
return round(min(1.0, max(0.0, score)), 3)
|
||||
|
||||
|
||||
def _compute_script_diversity(script: dict, history: list = None, **kwargs) -> float:
|
||||
"""
|
||||
Compute script structure diversity vs recent history.
|
||||
|
||||
Args:
|
||||
script: Current script dict with 'hook', 'body', 'closer'
|
||||
history: List of recent scripts (last 7 days), each same format
|
||||
|
||||
Returns: 0.0-1.0 diversity score (1.0 = very diverse)
|
||||
"""
|
||||
if not history:
|
||||
return 1.0
|
||||
|
||||
# Compare script structure fingerprints
|
||||
def _fingerprint(s: dict) -> tuple:
|
||||
hook = s.get('hook', '')
|
||||
body = s.get('body', [])
|
||||
closer = s.get('closer', '')
|
||||
return (
|
||||
len(hook) // 10, # rough length bucket
|
||||
len(body), # number of body sentences
|
||||
hook[:5] if hook else '', # hook start
|
||||
)
|
||||
|
||||
current_fp = _fingerprint(script)
|
||||
|
||||
overlaps = sum(
|
||||
1 for h in history
|
||||
if _fingerprint(h) == current_fp
|
||||
)
|
||||
|
||||
overlap_rate = overlaps / len(history)
|
||||
return round(1.0 - overlap_rate, 3)
|
||||
|
||||
|
||||
def _compute_tts_cost_efficiency(usage: float, limit: float, **kwargs) -> float:
|
||||
"""
|
||||
Compute TTS cost efficiency.
|
||||
|
||||
Args:
|
||||
usage: Characters used this period
|
||||
limit: Monthly/daily character limit
|
||||
|
||||
Returns: ratio (usage/limit), where > threshold triggers engine switch
|
||||
"""
|
||||
if limit <= 0:
|
||||
return 0.0
|
||||
return round(min(1.0, usage / limit), 3)
|
||||
|
||||
|
||||
# ── Standalone test ──────────────────────────────────────────────
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
if '--test' in sys.argv:
|
||||
print("=== Micro Signals Test ===")
|
||||
|
||||
# Test motion variation
|
||||
test_clips = [
|
||||
{'pattern': 'ken_burns_in'},
|
||||
{'pattern': 'ken_burns_in'}, # repeat!
|
||||
{'pattern': 'pan_left'},
|
||||
{'pattern': 'pan_right'},
|
||||
]
|
||||
mv = compute_signal('motion_variation_score', clips=test_clips)
|
||||
result = check_and_act('motion_variation_score', mv)
|
||||
print(f"motion_variation_score = {mv:.3f} (triggered: {result['triggered']}, action: {result['action']})")
|
||||
|
||||
# Test script diversity
|
||||
current_script = {'hook': '이거 모르면 손해', 'body': ['첫째', '둘째', '셋째'], 'closer': '구독'}
|
||||
history = [
|
||||
{'hook': '이거 모르면 손해2', 'body': ['a', 'b', 'c'], 'closer': '팔로우'},
|
||||
]
|
||||
sd = compute_signal('script_diversity_score', script=current_script, history=history)
|
||||
result2 = check_and_act('script_diversity_score', sd)
|
||||
print(f"script_diversity_score = {sd:.3f} (triggered: {result2['triggered']})")
|
||||
|
||||
# Test TTS cost
|
||||
tce = compute_signal('tts_cost_efficiency', usage=8500, limit=10000)
|
||||
result3 = check_and_act('tts_cost_efficiency', tce)
|
||||
print(f"tts_cost_efficiency = {tce:.3f} (triggered: {result3['triggered']}, action: {result3['action']})")
|
||||
@@ -13,7 +13,7 @@ from telegram import Update
|
||||
from telegram.ext import Application, MessageHandler, CommandHandler, filters, ContextTypes
|
||||
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
||||
|
||||
+127
-24
@@ -27,7 +27,7 @@ from telegram.ext import Application, CommandHandler, MessageHandler, filters, C
|
||||
import anthropic
|
||||
import re
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
CONFIG_DIR = BASE_DIR / 'config'
|
||||
@@ -55,28 +55,25 @@ ANTHROPIC_API_KEY = os.getenv('ANTHROPIC_API_KEY', '')
|
||||
_claude_client: anthropic.Anthropic | None = None
|
||||
_conversation_history: dict[int, list] = {}
|
||||
|
||||
CLAUDE_SYSTEM_PROMPT = """당신은 The 4th Path 블로그 자동 수익 엔진의 AI 어시스턴트입니다.
|
||||
CLAUDE_SYSTEM_PROMPT = """당신은 "AI? 그게 뭔데?" 블로그의 운영 어시스턴트입니다.
|
||||
블로그 운영자 eli가 Telegram으로 명령하면 도와주는 역할입니다.
|
||||
슬로건: "어렵지 않아요, 그냥 읽어봐요"
|
||||
블로그 주소: eli-ai.blogspot.com
|
||||
|
||||
이 시스템(v3)은 4계층 구조로 운영됩니다:
|
||||
|
||||
[LAYER 1] AI 콘텐츠 생성: OpenClaw(GPT-5.4)가 원본 마크다운 1개 생성
|
||||
[LAYER 1] AI 콘텐츠 생성: Gemini 2.5-flash가 원본 마크다운 1개 생성
|
||||
[LAYER 2] 변환 엔진: 원본 → 블로그HTML / 인스타카드 / X스레드 / 뉴스레터 자동 변환
|
||||
[LAYER 3] 배포 엔진: Blogger / Instagram / X / TikTok / YouTube 순차 발행
|
||||
[LAYER 4] 분석봇: 성과 수집 + 주간 리포트 + 피드백 루프
|
||||
|
||||
봇 구성:
|
||||
- collector_bot: 트렌드/RSS 수집 (07:00)
|
||||
- ai_writer: OpenClaw 글 작성 트리거 (08:00)
|
||||
- blog_converter: 마크다운→HTML (08:30)
|
||||
- card_converter: 인스타 카드 1080×1080 (08:30)
|
||||
- thread_converter: X 스레드 변환 (08:30)
|
||||
- publisher_bot: Blogger 발행 (09:00)
|
||||
- instagram_bot: 인스타 발행 (10:00)
|
||||
- x_bot: X 스레드 게시 (11:00)
|
||||
- analytics_bot: 분석/리포트 (22:00)
|
||||
8개 카테고리: AI인사이트, 여행맛집, 스타트업, 제품리뷰, 생활꿀팁, 앱추천, 재테크절약, 팩트체크
|
||||
|
||||
사용 가능한 텔레그램 명령:
|
||||
/status — 봇 상태
|
||||
/topics — 오늘 수집된 글감
|
||||
/collect — 글감 즉시 수집
|
||||
/write [번호] [방향] — 특정 글감으로 글 작성
|
||||
/pending — 검토 대기 글 목록
|
||||
/approve [번호] — 글 승인 및 발행
|
||||
/reject [번호] — 글 거부
|
||||
@@ -87,6 +84,7 @@ CLAUDE_SYSTEM_PROMPT = """당신은 The 4th Path 블로그 자동 수익 엔진
|
||||
/novel_gen [novel_id] — 에피소드 즉시 생성
|
||||
/novel_status — 소설 파이프라인 진행 현황
|
||||
|
||||
모든 글은 발행 전 운영자 승인이 필요합니다.
|
||||
사용자의 자연어 요청을 이해하고 적절히 안내하거나 답변해주세요.
|
||||
한국어로 간결하게 답변하세요."""
|
||||
IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
|
||||
@@ -153,16 +151,25 @@ def _safe_slug(text: str) -> str:
|
||||
|
||||
def _build_openclaw_prompt(topic_data: dict) -> tuple[str, str]:
|
||||
topic = topic_data.get('topic', '').strip()
|
||||
corner = topic_data.get('corner', '쉬운세상').strip() or '쉬운세상'
|
||||
corner = topic_data.get('corner', 'AI인사이트').strip() or 'AI인사이트'
|
||||
description = topic_data.get('description', '').strip()
|
||||
source = topic_data.get('source_url') or topic_data.get('source') or ''
|
||||
published_at = topic_data.get('published_at', '')
|
||||
is_english = topic_data.get('is_english', False)
|
||||
system = (
|
||||
"당신은 The 4th Path 블로그 엔진의 전문 에디터다. "
|
||||
"당신은 'AI? 그게 뭔데?' 블로그의 편집자 eli다. "
|
||||
"비전문가도 쉽게 읽을 수 있는 친근한 톤으로 글을 쓴다. "
|
||||
"반드시 아래 섹션 헤더 형식만 사용해 완성된 Blogger-ready HTML 원고를 출력하라. "
|
||||
"본문(BODY)은 HTML로 작성하고, KEY_POINTS는 3줄 이내로 작성한다."
|
||||
)
|
||||
prompt = f"""다음 글감을 바탕으로 한국어 블로그 원고를 작성해줘.
|
||||
if is_english:
|
||||
system += (
|
||||
" 영문 원문을 단순 번역하지 말고, 한국 독자 관점에서 재해석하여 작성하라. "
|
||||
"한국 시장/사용자에게 어떤 의미인지, 국내 대안이나 비교 서비스가 있다면 함께 언급하라. "
|
||||
"제목도 한국어로 매력적으로 새로 작성하라."
|
||||
)
|
||||
lang_note = "\n⚠️ 영문 원문입니다. 단순 번역이 아닌, 한국 독자 맥락으로 재작성해주세요." if is_english else ""
|
||||
prompt = f"""다음 글감을 바탕으로 한국어 블로그 원고를 작성해줘.{lang_note}
|
||||
|
||||
주제: {topic}
|
||||
코너: {corner}
|
||||
@@ -207,8 +214,8 @@ def _build_openclaw_prompt(topic_data: dict) -> tuple[str, str]:
|
||||
return system, prompt
|
||||
|
||||
|
||||
def _call_openclaw(topic_data: dict, output_path: Path):
|
||||
logger.info(f"OpenClaw 작성 요청: {topic_data.get('topic', '')}")
|
||||
def _call_openclaw(topic_data: dict, output_path: Path, direction: str = ''):
|
||||
logger.info(f"글 작성 요청: {topic_data.get('topic', '')}")
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
|
||||
@@ -216,18 +223,20 @@ def _call_openclaw(topic_data: dict, output_path: Path):
|
||||
from article_parser import parse_output
|
||||
|
||||
system, prompt = _build_openclaw_prompt(topic_data)
|
||||
if direction:
|
||||
prompt += f"\n\n운영자 지시사항: {direction}"
|
||||
writer = EngineLoader().get_writer()
|
||||
raw_output = writer.write(prompt, system=system).strip()
|
||||
if not raw_output:
|
||||
raise RuntimeError('OpenClaw writer 응답이 비어 있습니다.')
|
||||
raise RuntimeError('Writer 응답이 비어 있습니다.')
|
||||
|
||||
article = parse_output(raw_output)
|
||||
if not article:
|
||||
raise RuntimeError('OpenClaw writer 출력 파싱 실패')
|
||||
raise RuntimeError('Writer 출력 파싱 실패')
|
||||
|
||||
article.setdefault('title', topic_data.get('topic', '').strip())
|
||||
article['slug'] = article.get('slug') or _safe_slug(article['title'])
|
||||
article['corner'] = article.get('corner') or topic_data.get('corner', '쉬운세상')
|
||||
article['corner'] = article.get('corner') or topic_data.get('corner', 'AI인사이트')
|
||||
article['topic'] = topic_data.get('topic', '')
|
||||
article['description'] = topic_data.get('description', '')
|
||||
article['quality_score'] = topic_data.get('quality_score', 0)
|
||||
@@ -241,7 +250,28 @@ def _call_openclaw(topic_data: dict, output_path: Path):
|
||||
json.dumps(article, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8',
|
||||
)
|
||||
logger.info(f"OpenClaw 원고 저장 완료: {output_path.name}")
|
||||
logger.info(f"원고 저장 완료: {output_path.name}")
|
||||
|
||||
|
||||
def _publish_next():
|
||||
"""originals/ → pending_review/ 이동 (안전장치 체크)"""
|
||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||
import publisher_bot
|
||||
originals_dir = DATA_DIR / 'originals'
|
||||
pending_dir = DATA_DIR / 'pending_review'
|
||||
pending_dir.mkdir(exist_ok=True)
|
||||
safety_cfg = publisher_bot.load_config('safety_keywords.json')
|
||||
for f in sorted(originals_dir.glob('*.json')):
|
||||
try:
|
||||
article = json.loads(f.read_text(encoding='utf-8'))
|
||||
needs_review, reason = publisher_bot.check_safety(article, safety_cfg)
|
||||
article['pending_reason'] = reason or '수동 승인 필요'
|
||||
dest = pending_dir / f.name
|
||||
dest.write_text(json.dumps(article, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
f.unlink()
|
||||
logger.info(f"검토 대기로 이동: {f.name} ({reason})")
|
||||
except Exception as e:
|
||||
logger.error(f"publish_next 오류 ({f.name}): {e}")
|
||||
|
||||
|
||||
def job_convert():
|
||||
@@ -579,6 +609,76 @@ async def cmd_resume_publish(update: Update, context: ContextTypes.DEFAULT_TYPE)
|
||||
await update.message.reply_text("🟢 발행이 재개되었습니다.")
|
||||
|
||||
|
||||
async def cmd_collect(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text("🔄 글감 수집을 시작합니다...")
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
await loop.run_in_executor(None, job_collector)
|
||||
topics_dir = DATA_DIR / 'topics'
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
files = sorted(topics_dir.glob(f'{today}_*.json'))
|
||||
if not files:
|
||||
await update.message.reply_text("✅ 수집 완료! 오늘 수집된 글감이 없습니다.")
|
||||
return
|
||||
lines = [f"✅ 수집 완료! 오늘 글감 {len(files)}개:"]
|
||||
for i, f in enumerate(files[:15], 1):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding='utf-8'))
|
||||
lines.append(f" {i}. [{data.get('corner','')}] {data.get('topic','')[:50]}")
|
||||
except Exception:
|
||||
pass
|
||||
lines.append("\n✍️ /write [번호] 로 글 작성")
|
||||
await update.message.reply_text('\n'.join(lines))
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ 수집 오류: {e}")
|
||||
|
||||
|
||||
async def cmd_write(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
topics_dir = DATA_DIR / 'topics'
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
files = sorted(topics_dir.glob(f'{today}_*.json'))
|
||||
if not files:
|
||||
await update.message.reply_text("오늘 수집된 글감이 없습니다. /collect 먼저 실행하세요.")
|
||||
return
|
||||
args = context.args
|
||||
if not args:
|
||||
lines = ["📋 글감 목록 (번호를 선택하세요):"]
|
||||
for i, f in enumerate(files[:10], 1):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding='utf-8'))
|
||||
lines.append(f" {i}. [{data.get('corner','')}] {data.get('topic','')[:50]}")
|
||||
except Exception:
|
||||
pass
|
||||
lines.append("\n사용법: /write [번호] [방향(선택)]")
|
||||
await update.message.reply_text('\n'.join(lines))
|
||||
return
|
||||
try:
|
||||
idx = int(args[0]) - 1
|
||||
if idx < 0 or idx >= len(files):
|
||||
await update.message.reply_text(f"❌ 1~{len(files)} 사이 번호를 입력하세요.")
|
||||
return
|
||||
except ValueError:
|
||||
await update.message.reply_text("❌ 숫자를 입력하세요. 예: /write 1")
|
||||
return
|
||||
topic_file = files[idx]
|
||||
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
|
||||
direction = ' '.join(args[1:]) if len(args) > 1 else ''
|
||||
draft_path = DATA_DIR / 'originals' / topic_file.name
|
||||
(DATA_DIR / 'originals').mkdir(exist_ok=True)
|
||||
await update.message.reply_text(
|
||||
f"✍️ 글 작성 중...\n주제: {topic_data.get('topic','')[:50]}"
|
||||
+ (f"\n방향: {direction}" if direction else "")
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
await loop.run_in_executor(None, _call_openclaw, topic_data, draft_path, direction)
|
||||
# 자동으로 pending_review로 이동
|
||||
await loop.run_in_executor(None, _publish_next)
|
||||
await update.message.reply_text("✅ 완료! /pending 으로 검토하세요.")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ 글 작성 오류: {e}")
|
||||
|
||||
|
||||
async def cmd_show_topics(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
topics_dir = DATA_DIR / 'topics'
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
@@ -587,12 +687,13 @@ async def cmd_show_topics(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text("오늘 수집된 글감이 없습니다.")
|
||||
return
|
||||
lines = [f"📋 오늘 수집된 글감 ({len(files)}개):"]
|
||||
for f in files[:10]:
|
||||
for i, f in enumerate(files[:15], 1):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding='utf-8'))
|
||||
lines.append(f" [{data.get('quality_score',0)}점][{data.get('corner','')}] {data.get('topic','')[:50]}")
|
||||
lines.append(f" {i}. [{data.get('quality_score',0)}점][{data.get('corner','')}] {data.get('topic','')[:50]}")
|
||||
except Exception:
|
||||
pass
|
||||
lines.append("\n✍️ /write [번호] 로 글 작성")
|
||||
await update.message.reply_text('\n'.join(lines))
|
||||
|
||||
|
||||
@@ -1126,6 +1227,8 @@ async def main():
|
||||
|
||||
# 발행 관련
|
||||
app.add_handler(CommandHandler('status', cmd_status))
|
||||
app.add_handler(CommandHandler('collect', cmd_collect))
|
||||
app.add_handler(CommandHandler('write', cmd_write))
|
||||
app.add_handler(CommandHandler('approve', cmd_approve))
|
||||
app.add_handler(CommandHandler('reject', cmd_reject))
|
||||
app.add_handler(CommandHandler('pending', cmd_pending))
|
||||
|
||||
@@ -20,6 +20,105 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
CAPTION_TEMPLATES = {
|
||||
'hormozi': {
|
||||
'font_size': 64,
|
||||
'highlight_color': '#FFD700',
|
||||
'animation': 'pop_in',
|
||||
'position': 'center',
|
||||
'outline_width': 4,
|
||||
'auto_emoji': False,
|
||||
},
|
||||
'tiktok_viral': {
|
||||
'font_size': 56,
|
||||
'highlight_color': '#FF6B6B',
|
||||
'animation': 'bounce',
|
||||
'auto_emoji': True,
|
||||
'position': 'center_bottom',
|
||||
},
|
||||
'brand_4thpath': {
|
||||
'font_size': 52,
|
||||
'highlight_color': '#00D4FF',
|
||||
'animation': 'typewriter',
|
||||
'position': 'center',
|
||||
'overlay_gradient': True,
|
||||
},
|
||||
}
|
||||
|
||||
# Corner → caption template mapping
|
||||
CORNER_CAPTION_MAP = {
|
||||
'쉬운세상': 'hormozi',
|
||||
'숨은보물': 'tiktok_viral',
|
||||
'바이브리포트': 'hormozi',
|
||||
'팩트체크': 'brand_4thpath',
|
||||
'한컷': 'tiktok_viral',
|
||||
'웹소설': 'brand_4thpath',
|
||||
}
|
||||
|
||||
|
||||
def smart_line_break(text: str, max_chars: int = 18) -> list[str]:
|
||||
"""
|
||||
Break Korean text at semantic boundaries, not mid-word.
|
||||
Never break before 조사 (particles) or 어미 (endings).
|
||||
|
||||
Returns list of line strings.
|
||||
"""
|
||||
# Common Korean particles/endings that should not start a new line
|
||||
PARTICLES = ['은', '는', '이', '가', '을', '를', '의', '에', '에서', '으로', '로',
|
||||
'과', '와', '도', '만', '까지', '부터', '보다', '처럼', '같이',
|
||||
'한테', '에게', '이라', '라고', '이고', '이며', '고', '며', '면',
|
||||
'이면', '이나', '나', '든지', '거나', '지만', '이지만', '지만',
|
||||
'니까', '으니까', '이니까', '서', '아서', '어서', '며', '고']
|
||||
|
||||
if len(text) <= max_chars:
|
||||
return [text] if text else []
|
||||
|
||||
lines = []
|
||||
remaining = text
|
||||
|
||||
while len(remaining) > max_chars:
|
||||
# Find best break point near max_chars
|
||||
break_at = max_chars
|
||||
|
||||
# Look for space or punctuation near the limit
|
||||
for i in range(max_chars, max(0, max_chars - 6), -1):
|
||||
if i >= len(remaining):
|
||||
continue
|
||||
char = remaining[i]
|
||||
prev_char = remaining[i-1] if i > 0 else ''
|
||||
next_char = remaining[i+1] if i+1 < len(remaining) else ''
|
||||
|
||||
# Break at space
|
||||
if char == ' ':
|
||||
# Check if next word starts with a particle
|
||||
next_word = remaining[i+1:i+4]
|
||||
is_particle_start = any(next_word.startswith(p) for p in PARTICLES)
|
||||
if not is_particle_start:
|
||||
break_at = i
|
||||
break
|
||||
|
||||
# Break after punctuation
|
||||
if prev_char in ('。', ',', ',', '.', '!', '?', '~'):
|
||||
break_at = i
|
||||
break
|
||||
|
||||
lines.append(remaining[:break_at].strip())
|
||||
remaining = remaining[break_at:].strip()
|
||||
|
||||
if remaining:
|
||||
lines.append(remaining)
|
||||
|
||||
return [l for l in lines if l]
|
||||
|
||||
|
||||
def get_template_for_corner(corner: str) -> dict:
|
||||
"""
|
||||
Get caption template config for a given content corner.
|
||||
Falls back to 'hormozi' template if corner not in map.
|
||||
"""
|
||||
template_name = CORNER_CAPTION_MAP.get(corner, 'hormozi')
|
||||
return CAPTION_TEMPLATES.get(template_name, CAPTION_TEMPLATES['hormozi'])
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
cfg_path = BASE_DIR / 'config' / 'shorts_config.json'
|
||||
@@ -200,6 +299,7 @@ def render_captions(
|
||||
timestamp: str,
|
||||
wav_duration: float = 0.0,
|
||||
cfg: Optional[dict] = None,
|
||||
corner: str = '',
|
||||
) -> Path:
|
||||
"""
|
||||
스크립트 + 단어별 타임스탬프 → ASS 자막 파일 생성.
|
||||
@@ -211,6 +311,7 @@ def render_captions(
|
||||
timestamp: 파일명 prefix
|
||||
wav_duration: TTS 오디오 총 길이 (균등 분할 폴백용)
|
||||
cfg: shorts_config.json dict
|
||||
corner: content corner name (e.g. '쉬운세상') for template selection
|
||||
|
||||
Returns:
|
||||
ass_path
|
||||
@@ -222,6 +323,20 @@ def render_captions(
|
||||
ass_path = output_dir / f'{timestamp}.ass'
|
||||
|
||||
cap_cfg = cfg.get('caption', {})
|
||||
|
||||
# Apply corner-specific template overrides if corner is provided
|
||||
if corner:
|
||||
template = get_template_for_corner(corner)
|
||||
# Override cfg caption section with template values
|
||||
cap_cfg = dict(cap_cfg) # make a shallow copy to avoid mutating original
|
||||
if 'font_size' in template:
|
||||
cap_cfg['font_size'] = template['font_size']
|
||||
if 'highlight_color' in template:
|
||||
cap_cfg['highlight_color'] = template['highlight_color']
|
||||
if 'outline_width' in template:
|
||||
cap_cfg['outline_width'] = template['outline_width']
|
||||
logger.info(f'[캡션] 코너 "{corner}" → 템플릿 적용: {template}')
|
||||
|
||||
max_chars = cap_cfg.get('max_chars_per_line_ko', 18)
|
||||
highlight_color = cap_cfg.get('highlight_color', '#FFD700')
|
||||
default_color = cap_cfg.get('default_color', '#FFFFFF')
|
||||
@@ -235,8 +350,10 @@ def render_captions(
|
||||
wav_duration = 20.0
|
||||
timestamps = _build_uniform_timestamps(script, wav_duration)
|
||||
|
||||
# ASS 헤더
|
||||
header = _ass_header(cfg)
|
||||
# ASS 헤더 (rebuild cfg with updated cap_cfg so header reflects template overrides)
|
||||
effective_cfg = dict(cfg)
|
||||
effective_cfg['caption'] = cap_cfg
|
||||
header = _ass_header(effective_cfg)
|
||||
events = []
|
||||
|
||||
# 훅 이벤트 (첫 1.5초 중앙 표시)
|
||||
@@ -258,4 +375,58 @@ def render_captions(
|
||||
ass_content = header + '\n'.join(events) + '\n'
|
||||
ass_path.write_text(ass_content, encoding='utf-8-sig') # BOM for Windows compatibility
|
||||
logger.info(f'ASS 자막 생성: {ass_path.name} ({len(timestamps)}단어, {len(lines)}라인)')
|
||||
|
||||
|
||||
# ── Standalone test ──────────────────────────────────────────────
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
if '--test' not in sys.argv:
|
||||
print("사용법: python -m bots.shorts.caption_renderer --test")
|
||||
sys.exit(0)
|
||||
|
||||
print("=== Caption Renderer Test ===")
|
||||
|
||||
# Test smart_line_break
|
||||
test_texts = [
|
||||
("AI를 활용한 자동화 방법입니다", 18),
|
||||
("단 3가지만 알면 됩니다", 12),
|
||||
]
|
||||
print("\n[1] smart_line_break:")
|
||||
for text, max_c in test_texts:
|
||||
lines = smart_line_break(text, max_c)
|
||||
print(f" 입력: {text!r}")
|
||||
print(f" 결과: {lines}")
|
||||
|
||||
# Test template lookup
|
||||
print("\n[2] get_template_for_corner:")
|
||||
for corner in ['쉬운세상', '숨은보물', '팩트체크', '없는코너']:
|
||||
tpl = get_template_for_corner(corner)
|
||||
print(f" {corner}: font_size={tpl.get('font_size')}, animation={tpl.get('animation')}")
|
||||
|
||||
# Test render_captions with dummy timestamps
|
||||
print("\n[3] render_captions (dry-run):")
|
||||
sample_timestamps = [
|
||||
{'word': '이거', 'start': 0.0, 'end': 0.3},
|
||||
{'word': '모르면', 'start': 0.4, 'end': 0.8},
|
||||
{'word': '손해입니다', 'start': 0.9, 'end': 1.5},
|
||||
]
|
||||
sample_script = {'hook': '이거 모르면 손해입니다'}
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
out = Path(tmpdir) / 'test.ass'
|
||||
render_captions(
|
||||
timestamps=sample_timestamps,
|
||||
script=sample_script,
|
||||
output_path=out,
|
||||
corner='쉬운세상',
|
||||
)
|
||||
exists = out.exists()
|
||||
size = out.stat().st_size if exists else 0
|
||||
print(f" ASS 파일 생성: {exists}, 크기: {size}bytes")
|
||||
assert exists and size > 0, "ASS 파일 생성 실패"
|
||||
|
||||
print("\n✅ 모든 테스트 통과")
|
||||
return ass_path
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
bots/shorts/hook_optimizer.py
|
||||
Hook text quality scoring and optimization.
|
||||
|
||||
HookOptimizer:
|
||||
- score(hook): 0-100 quality score based on pattern match + keyword strength
|
||||
- optimize(hook, article, max_attempts): regenerate if score < 70
|
||||
|
||||
V3.0 scope: pattern matching + LLM regeneration via existing writer_bot
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hook patterns mapped to template strings with {N} placeholder for numbers
|
||||
HOOK_PATTERNS = {
|
||||
'disbelief': [
|
||||
'이거 모르면 손해',
|
||||
'이게 무료라고?',
|
||||
'이걸 아직도 모른다고?',
|
||||
'믿기 힘들지만 사실입니다',
|
||||
'실화입니다',
|
||||
],
|
||||
'warning': [
|
||||
'절대 하지 마세요',
|
||||
'이것만은 피하세요',
|
||||
'지금 당장 멈추세요',
|
||||
'알면 충격받을 수 있습니다',
|
||||
],
|
||||
'number': [
|
||||
'단 {N}초면',
|
||||
'{N}%가 모르는',
|
||||
'{N}가지 방법',
|
||||
'{N}배 빠른',
|
||||
'상위 {N}%',
|
||||
],
|
||||
'question': [
|
||||
'왜 아무도 안 알려줄까?',
|
||||
'진짜일까?',
|
||||
'이게 가능한 이유',
|
||||
'어떻게 하는 걸까?',
|
||||
],
|
||||
'urgency': [
|
||||
'지금 당장',
|
||||
'오늘 안에',
|
||||
'지금 안 보면 후회',
|
||||
'당장 시작해야 하는 이유',
|
||||
],
|
||||
}
|
||||
|
||||
# High-value keywords that boost score (Korean viral hook words)
|
||||
HIGH_VALUE_KEYWORDS = [
|
||||
'무료', '공짜', '비밀', '충격', '실화', '진짜', '불법',
|
||||
'모르는', '숨겨진', '알려지지 않은', '믿기지 않는', '손해',
|
||||
'당장', '지금', '반드시', '절대', '꼭', '필수',
|
||||
'돈', '수익', '수입', '부자', '성공', '자유',
|
||||
'초보', '누구나', '쉬운', '간단한',
|
||||
]
|
||||
|
||||
# Weak words that reduce score
|
||||
WEAK_KEYWORDS = [
|
||||
'알아보겠습니다', '살펴보겠습니다', '설명드리겠습니다',
|
||||
'안녕하세요', '오늘은', '이번에는', '먼저',
|
||||
]
|
||||
|
||||
|
||||
class HookOptimizer:
|
||||
"""
|
||||
Scores and optimizes hook text for shorts videos.
|
||||
|
||||
Score = pattern_score (0-50) + keyword_score (0-30) + length_score (0-20)
|
||||
Threshold: 70 — below this triggers regeneration
|
||||
"""
|
||||
|
||||
def __init__(self, threshold: int = 70):
|
||||
self.threshold = threshold
|
||||
self._recently_used_patterns: list[str] = [] # avoid repetition
|
||||
|
||||
def score(self, hook: str) -> int:
|
||||
"""
|
||||
Score a hook text from 0-100.
|
||||
|
||||
Components:
|
||||
- pattern_score (0-50): does it match a known viral pattern?
|
||||
- keyword_score (0-30): does it contain high-value keywords?
|
||||
- length_score (0-20): optimal length (15-30 chars = max)
|
||||
"""
|
||||
if not hook:
|
||||
return 0
|
||||
|
||||
pattern_score = self._score_pattern(hook)
|
||||
keyword_score = self._score_keywords(hook)
|
||||
length_score = self._score_length(hook)
|
||||
|
||||
total = min(100, pattern_score + keyword_score + length_score)
|
||||
return total
|
||||
|
||||
def optimize(
|
||||
self,
|
||||
hook: str,
|
||||
article: dict,
|
||||
max_attempts: int = 3,
|
||||
llm_fn=None,
|
||||
) -> str:
|
||||
"""
|
||||
Score hook. If score < threshold, regenerate up to max_attempts times.
|
||||
|
||||
Args:
|
||||
hook: Initial hook text
|
||||
article: Article dict with keys: title, body, corner, key_points
|
||||
max_attempts: Max regeneration attempts
|
||||
llm_fn: Optional callable(prompt) -> str for LLM regeneration.
|
||||
If None, returns original hook (LLM not available).
|
||||
|
||||
Returns: Best hook found (may still be below threshold if all attempts fail)
|
||||
"""
|
||||
current = hook
|
||||
best = hook
|
||||
best_score = self.score(hook)
|
||||
|
||||
logger.info(f'[훅] 초기 점수: {best_score}/100 — "{hook[:30]}..."')
|
||||
|
||||
if best_score >= self.threshold:
|
||||
return hook
|
||||
|
||||
if llm_fn is None:
|
||||
logger.warning(f'[훅] 점수 부족 ({best_score}/100) — LLM 없음, 원본 사용')
|
||||
return hook
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
prompt = self._build_regeneration_prompt(current, article, best_score)
|
||||
|
||||
try:
|
||||
new_hook = llm_fn(prompt)
|
||||
if new_hook:
|
||||
new_hook = new_hook.strip().split('\n')[0] # Take first line
|
||||
new_score = self.score(new_hook)
|
||||
logger.info(f'[훅] 시도 {attempt+1}: {new_score}/100 — "{new_hook[:30]}"')
|
||||
|
||||
if new_score > best_score:
|
||||
best = new_hook
|
||||
best_score = new_score
|
||||
|
||||
if best_score >= self.threshold:
|
||||
break
|
||||
|
||||
current = new_hook
|
||||
except Exception as e:
|
||||
logger.warning(f'[훅] LLM 재생성 실패 (시도 {attempt+1}): {e}')
|
||||
break
|
||||
|
||||
logger.info(f'[훅] 최종 점수: {best_score}/100 — "{best[:30]}"')
|
||||
return best
|
||||
|
||||
def _score_pattern(self, hook: str) -> int:
|
||||
"""Check if hook matches known viral patterns. Max 50 points."""
|
||||
for pattern_name, templates in HOOK_PATTERNS.items():
|
||||
for template in templates:
|
||||
# Check for fuzzy match (template with {N} filled in)
|
||||
pattern_re = re.escape(template).replace(r'\{N\}', r'\d+')
|
||||
if re.search(pattern_re, hook):
|
||||
# Recently used pattern gets reduced score
|
||||
if pattern_name in self._recently_used_patterns[-3:]:
|
||||
return 30
|
||||
self._recently_used_patterns.append(pattern_name)
|
||||
return 50
|
||||
# Partial match check
|
||||
core = template.replace('{N}', '').strip()
|
||||
if len(core) > 3 and core in hook:
|
||||
return 35
|
||||
return 0
|
||||
|
||||
def _score_keywords(self, hook: str) -> int:
|
||||
"""Score based on high-value/weak keywords. Max 30 points."""
|
||||
score = 0
|
||||
for kw in HIGH_VALUE_KEYWORDS:
|
||||
if kw in hook:
|
||||
score += 10
|
||||
if score >= 30:
|
||||
break
|
||||
|
||||
# Penalize weak words
|
||||
for kw in WEAK_KEYWORDS:
|
||||
if kw in hook:
|
||||
score -= 15
|
||||
|
||||
return max(0, min(30, score))
|
||||
|
||||
def _score_length(self, hook: str) -> int:
|
||||
"""Score based on hook length. Max 20 points. Optimal: 15-30 chars."""
|
||||
length = len(hook)
|
||||
if 15 <= length <= 30:
|
||||
return 20
|
||||
elif 10 <= length < 15 or 30 < length <= 40:
|
||||
return 10
|
||||
elif length < 10:
|
||||
return 5
|
||||
else: # > 40 chars
|
||||
return 0
|
||||
|
||||
def _build_regeneration_prompt(self, hook: str, article: dict, current_score: int) -> str:
|
||||
"""Build LLM prompt for hook regeneration."""
|
||||
title = article.get('title', '')
|
||||
corner = article.get('corner', '')
|
||||
key_points = article.get('key_points', [])
|
||||
recently_used = ', '.join(self._recently_used_patterns[-3:]) if self._recently_used_patterns else '없음'
|
||||
|
||||
points_str = '\n'.join(f'- {p}' for p in key_points[:3]) if key_points else ''
|
||||
|
||||
return f"""다음 쇼츠 영상의 훅 텍스트를 개선해주세요.
|
||||
|
||||
현재 훅: {hook}
|
||||
현재 점수: {current_score}/100 (기준: 70점 이상)
|
||||
|
||||
콘텐츠 정보:
|
||||
- 제목: {title}
|
||||
- 코너: {corner}
|
||||
- 핵심 포인트: {points_str}
|
||||
|
||||
요구사항:
|
||||
1. 15-30자 이내
|
||||
2. 다음 패턴 중 하나 사용: 충격/의심/경고/숫자/긴급
|
||||
3. 최근 사용된 패턴 제외: {recently_used}
|
||||
4. 한국어로 작성
|
||||
5. 훅 텍스트만 출력 (설명 없이)
|
||||
|
||||
개선된 훅:"""
|
||||
|
||||
|
||||
# ── Standalone test ──────────────────────────────────────────────
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
if '--test' in sys.argv:
|
||||
optimizer = HookOptimizer()
|
||||
test_hooks = [
|
||||
'이거 모르면 손해입니다!',
|
||||
'안녕하세요 오늘은 AI에 대해 설명드리겠습니다',
|
||||
'100%가 모르는 무료 도구',
|
||||
'지금 당장 이것만은 절대 하지 마세요',
|
||||
'어',
|
||||
]
|
||||
print("=== Hook Optimizer Test ===")
|
||||
for hook in test_hooks:
|
||||
s = optimizer.score(hook)
|
||||
print(f'점수 {s:3d}/100: "{hook}"')
|
||||
print()
|
||||
print("Pattern test:")
|
||||
for category in HOOK_PATTERNS:
|
||||
print(f" {category}: {len(HOOK_PATTERNS[category])} patterns")
|
||||
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
bots/shorts/motion_engine.py
|
||||
Motion pattern engine for video clips.
|
||||
|
||||
Applies one of 7 motion patterns to still images using FFmpeg.
|
||||
Ensures no 2 consecutive clips use the same pattern.
|
||||
|
||||
Patterns:
|
||||
1. ken_burns_in — slow zoom in
|
||||
2. ken_burns_out — slow zoom out
|
||||
3. pan_left — pan from right to left
|
||||
4. pan_right — pan from left to right
|
||||
5. parallax — layered depth effect (approximated)
|
||||
6. rotate_slow — very slow rotation
|
||||
7. glitch_reveal — glitch-style reveal
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PATTERNS = [
|
||||
'ken_burns_in',
|
||||
'ken_burns_out',
|
||||
'pan_left',
|
||||
'pan_right',
|
||||
'parallax',
|
||||
'rotate_slow',
|
||||
'glitch_reveal',
|
||||
]
|
||||
|
||||
# FFmpeg filter_complex expressions for each pattern
|
||||
# Input: scale to 1120x1990 (slightly larger than 1080x1920 for motion room)
|
||||
PATTERN_FILTERS = {
|
||||
'ken_burns_in': (
|
||||
"scale=1120:1990,"
|
||||
"zoompan=z='min(zoom+0.0008,1.08)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
|
||||
":d={dur_frames}:s=1080x1920:fps=30"
|
||||
),
|
||||
'ken_burns_out': (
|
||||
"scale=1120:1990,"
|
||||
"zoompan=z='if(lte(zoom,1.0),1.08,max(zoom-0.0008,1.0))'"
|
||||
":x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
|
||||
":d={dur_frames}:s=1080x1920:fps=30"
|
||||
),
|
||||
'pan_left': (
|
||||
"scale=1200:1920,"
|
||||
"crop=1080:1920:'min(iw-ow, (iw-ow)*t/{duration})':0"
|
||||
),
|
||||
'pan_right': (
|
||||
"scale=1200:1920,"
|
||||
"crop=1080:1920:'(iw-ow)*(1-t/{duration})':0"
|
||||
),
|
||||
'parallax': (
|
||||
# Approximate parallax: zoom + horizontal pan
|
||||
"scale=1200:1990,"
|
||||
"zoompan=z='1.05':x='iw/2-(iw/zoom/2)+50*sin(2*PI*t/{duration})'"
|
||||
":y='ih/2-(ih/zoom/2)':d={dur_frames}:s=1080x1920:fps=30"
|
||||
),
|
||||
'rotate_slow': (
|
||||
"scale=1200:1200,"
|
||||
"rotate='0.02*t':c=black:ow=1080:oh=1920"
|
||||
),
|
||||
'glitch_reveal': (
|
||||
# Fade in with slight chromatic aberration approximation
|
||||
"scale=1080:1920,"
|
||||
"fade=t=in:st=0:d=0.3,"
|
||||
"hue=h='if(lt(t,0.3),10*sin(30*t),0)'"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class MotionEngine:
|
||||
"""
|
||||
Applies motion patterns to still images.
|
||||
Auto-selects patterns to avoid repeating the last 2 used.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._recent: list[str] = [] # last patterns used (max 2)
|
||||
self._ffmpeg = os.environ.get('FFMPEG_PATH', 'ffmpeg')
|
||||
|
||||
def apply(self, image_path: str, duration: float, output_path: Optional[str] = None) -> str:
|
||||
"""
|
||||
Apply a motion pattern to a still image.
|
||||
|
||||
Args:
|
||||
image_path: Path to input image (PNG/JPG, 1080x1920 recommended)
|
||||
duration: Duration of output video in seconds
|
||||
output_path: Output MP4 path. If None, creates temp file.
|
||||
|
||||
Returns: Path to motion-applied video clip (MP4)
|
||||
"""
|
||||
if output_path is None:
|
||||
# Create a temp file that persists (caller is responsible for cleanup)
|
||||
tmp = tempfile.NamedTemporaryFile(suffix='.mp4', delete=False)
|
||||
output_path = tmp.name
|
||||
tmp.close()
|
||||
|
||||
pattern = self._next_pattern()
|
||||
success = self._ffmpeg_motion(image_path, duration, pattern, output_path)
|
||||
|
||||
if not success:
|
||||
logger.warning(f'[모션] {pattern} 패턴 실패 — ken_burns_in으로 폴백')
|
||||
success = self._ffmpeg_motion(image_path, duration, 'ken_burns_in', output_path)
|
||||
|
||||
if success:
|
||||
logger.info(f'[모션] 패턴 적용: {pattern} ({duration:.1f}초)')
|
||||
return output_path
|
||||
else:
|
||||
logger.error(f'[모션] 모든 패턴 실패: {image_path}')
|
||||
return ''
|
||||
|
||||
def _next_pattern(self) -> str:
|
||||
"""Select next pattern, avoiding last 2 used."""
|
||||
available = [p for p in PATTERNS if p not in self._recent[-2:]]
|
||||
if not available:
|
||||
available = PATTERNS
|
||||
choice = random.choice(available)
|
||||
self._recent.append(choice)
|
||||
if len(self._recent) > 4: # Keep small buffer
|
||||
self._recent = self._recent[-4:]
|
||||
return choice
|
||||
|
||||
def _ffmpeg_motion(self, image_path: str, duration: float,
|
||||
pattern: str, output_path: str) -> bool:
|
||||
"""Apply a specific motion pattern using FFmpeg."""
|
||||
dur_frames = int(duration * 30)
|
||||
|
||||
vf_template = PATTERN_FILTERS.get(pattern, PATTERN_FILTERS['ken_burns_in'])
|
||||
vf = vf_template.format(
|
||||
duration=f'{duration:.3f}',
|
||||
dur_frames=dur_frames,
|
||||
)
|
||||
|
||||
cmd = [
|
||||
self._ffmpeg, '-y',
|
||||
'-loop', '1',
|
||||
'-i', str(image_path),
|
||||
'-t', f'{duration:.3f}',
|
||||
'-vf', vf,
|
||||
'-c:v', 'libx264',
|
||||
'-crf', '20',
|
||||
'-preset', 'fast',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-an',
|
||||
'-r', '30',
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(f'[모션] FFmpeg 오류 ({pattern}): {result.stderr.decode(errors="ignore")[-200:]}')
|
||||
return False
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(f'[모션] 타임아웃 ({pattern})')
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f'[모션] 예외 ({pattern}): {e}')
|
||||
return False
|
||||
|
||||
def get_recent(self) -> list[str]:
|
||||
"""Return recently used patterns."""
|
||||
return list(self._recent)
|
||||
|
||||
|
||||
# ── Standalone test ──────────────────────────────────────────────
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
if '--test' in sys.argv:
|
||||
engine = MotionEngine()
|
||||
print("=== Motion Engine Test ===")
|
||||
print(f"Available patterns: {PATTERNS}")
|
||||
print(f"Pattern sequence (10 picks):")
|
||||
for i in range(10):
|
||||
p = engine._next_pattern()
|
||||
print(f" {i+1}. {p}")
|
||||
print(f"No pattern repeated consecutively: ", end='')
|
||||
recent_list = engine.get_recent()
|
||||
no_consec = all(
|
||||
recent_list[i] != recent_list[i+1]
|
||||
for i in range(len(recent_list)-1)
|
||||
)
|
||||
print("PASS" if no_consec else "FAIL")
|
||||
+210
-2
@@ -25,6 +25,111 @@ from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── SmartTTSRouter ───────────────────────────────────────────
|
||||
|
||||
class SmartTTSRouter:
|
||||
"""
|
||||
Budget-aware TTS engine selection with graceful fallback.
|
||||
|
||||
Engine priority order (best to cheapest):
|
||||
1. elevenlabs — best quality, paid
|
||||
2. openai_tts — good quality, paid (uses existing OpenAI key)
|
||||
3. cosyvoice2 — local, free, Korean native speaker voice
|
||||
4. kokoro — local, free, 82M params
|
||||
5. edge_tts — free fallback, always available
|
||||
"""
|
||||
|
||||
ENGINE_PRIORITY = ['elevenlabs', 'openai_tts', 'cosyvoice2', 'kokoro', 'edge_tts']
|
||||
|
||||
# Daily/monthly usage limits per engine
|
||||
ENGINE_LIMITS = {
|
||||
'elevenlabs': {'chars_per_month': 10000, 'threshold': 0.8},
|
||||
'openai_tts': {'chars_per_day': 500000, 'threshold': 0.9},
|
||||
}
|
||||
|
||||
ENGINE_API_KEYS = {
|
||||
'elevenlabs': 'ELEVENLABS_API_KEY',
|
||||
'openai_tts': 'OPENAI_API_KEY',
|
||||
}
|
||||
# cosyvoice2, kokoro, edge_tts are local — no API key needed
|
||||
|
||||
def __init__(self, resolved_config: dict):
|
||||
"""
|
||||
resolved_config: output from ConfigResolver.resolve()
|
||||
"""
|
||||
self.budget = resolved_config.get('budget', 'free')
|
||||
self.tts_engine = resolved_config.get('tts', 'edge_tts')
|
||||
self._usage = {} # {engine_name: chars_used_today}
|
||||
self._failed = set() # engines that failed this session
|
||||
|
||||
def select(self, text_length: int) -> str:
|
||||
"""
|
||||
Select best available TTS engine for given text length.
|
||||
|
||||
1. If user specified a non-auto engine: use it if available
|
||||
2. Else: check budget-appropriate engines in priority order
|
||||
3. Skip engines that have exceeded usage threshold
|
||||
4. Skip engines that failed this session
|
||||
5. Always fall back to edge_tts
|
||||
"""
|
||||
import os
|
||||
|
||||
# If user explicitly chose a specific engine (not 'auto')
|
||||
if self.tts_engine not in ('auto', 'edge_tts', ''):
|
||||
engine = self.tts_engine
|
||||
api_key_env = self.ENGINE_API_KEYS.get(engine, '')
|
||||
if not api_key_env or os.environ.get(api_key_env, ''):
|
||||
if engine not in self._failed:
|
||||
return engine
|
||||
|
||||
# Budget-based priority selection
|
||||
if self.budget == 'free':
|
||||
priority = ['kokoro', 'edge_tts']
|
||||
elif self.budget == 'low':
|
||||
priority = ['openai_tts', 'kokoro', 'edge_tts']
|
||||
else: # medium, premium
|
||||
priority = self.ENGINE_PRIORITY
|
||||
|
||||
for engine in priority:
|
||||
if engine in self._failed:
|
||||
continue
|
||||
api_key_env = self.ENGINE_API_KEYS.get(engine, '')
|
||||
if api_key_env and not os.environ.get(api_key_env, ''):
|
||||
continue # no API key
|
||||
if self._is_over_limit(engine, text_length):
|
||||
continue
|
||||
return engine
|
||||
|
||||
return 'edge_tts' # always available
|
||||
|
||||
def on_failure(self, engine: str, error: str) -> str:
|
||||
"""
|
||||
Record engine failure and return next available engine.
|
||||
No retry on same engine — no wasted credits.
|
||||
"""
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f'TTS 엔진 실패: {engine} — {error}, 다음 엔진으로 전환')
|
||||
self._failed.add(engine)
|
||||
return self.select(0) # Select next engine
|
||||
|
||||
def record_usage(self, engine: str, char_count: int) -> None:
|
||||
"""Record character usage for an engine."""
|
||||
self._usage[engine] = self._usage.get(engine, 0) + char_count
|
||||
|
||||
def _is_over_limit(self, engine: str, text_length: int) -> bool:
|
||||
"""Check if engine has exceeded its usage threshold."""
|
||||
limits = self.ENGINE_LIMITS.get(engine, {})
|
||||
if not limits:
|
||||
return False
|
||||
threshold = limits.get('threshold', 0.9)
|
||||
daily_limit = limits.get('chars_per_day', limits.get('chars_per_month', 0))
|
||||
if not daily_limit:
|
||||
return False
|
||||
used = self._usage.get(engine, 0)
|
||||
return (used + text_length) / daily_limit > threshold
|
||||
|
||||
|
||||
# ─── 공통 유틸 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -167,6 +272,47 @@ def _get_ffmpeg() -> str:
|
||||
return 'ffmpeg'
|
||||
|
||||
|
||||
# ─── OpenAI TTS ───────────────────────────────────────────────
|
||||
|
||||
def _tts_openai(text: str, output_path: Path, cfg: dict) -> list[dict]:
|
||||
"""
|
||||
OpenAI TTS (tts-1-hd model) with timestamp estimation.
|
||||
Returns: [{word, start, end}, ...] — uniform timestamps (no word-level from OpenAI)
|
||||
"""
|
||||
import requests, base64
|
||||
import os
|
||||
|
||||
api_key = os.environ.get('OPENAI_API_KEY', '')
|
||||
if not api_key:
|
||||
raise RuntimeError('OPENAI_API_KEY not set')
|
||||
|
||||
openai_cfg = cfg.get('tts', {}).get('openai', {})
|
||||
model = openai_cfg.get('model', 'tts-1-hd')
|
||||
voice = openai_cfg.get('voice', 'alloy')
|
||||
speed = openai_cfg.get('speed', 1.0)
|
||||
|
||||
url = 'https://api.openai.com/v1/audio/speech'
|
||||
headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'}
|
||||
payload = {
|
||||
'model': model,
|
||||
'input': text,
|
||||
'voice': voice,
|
||||
'speed': speed,
|
||||
'response_format': 'mp3',
|
||||
}
|
||||
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
resp.raise_for_status()
|
||||
|
||||
mp3_tmp = output_path.with_suffix('.mp3')
|
||||
mp3_tmp.write_bytes(resp.content)
|
||||
_mp3_to_wav(mp3_tmp, output_path)
|
||||
mp3_tmp.unlink(missing_ok=True)
|
||||
|
||||
# OpenAI TTS has no word-level timestamps — use uniform distribution
|
||||
return [] # caption_renderer will use uniform fallback
|
||||
|
||||
|
||||
# ─── Google Cloud TTS ─────────────────────────────────────────
|
||||
|
||||
def _tts_google_cloud(text: str, output_path: Path, cfg: dict) -> list[dict]:
|
||||
@@ -323,11 +469,21 @@ def generate_tts(
|
||||
ts_path = output_dir / f'{timestamp}_timestamps.json'
|
||||
|
||||
text = _concat_script(script)
|
||||
pause_ms = cfg.get('tts', {}).get('inter_sentence_pause_ms', 300)
|
||||
priority = cfg.get('tts', {}).get('engine_priority', ['elevenlabs', 'google_cloud', 'edge_tts'])
|
||||
|
||||
# Apply Korean preprocessing if available
|
||||
try:
|
||||
from bots.prompt_layer.korean_preprocessor import preprocess_korean
|
||||
text = preprocess_korean(text)
|
||||
except ImportError:
|
||||
pass # Korean preprocessing not available, use raw text
|
||||
|
||||
pause_ms = cfg.get('tts', {}).get('inter_sentence_pause_ms', 300)
|
||||
priority = cfg.get('tts', {}).get('engine_priority', ['elevenlabs', 'openai_tts', 'google_cloud', 'edge_tts'])
|
||||
|
||||
# Engine map: elevenlabs → openai_tts → google_cloud → edge_tts
|
||||
engine_map = {
|
||||
'elevenlabs': _tts_elevenlabs,
|
||||
'openai_tts': _tts_openai,
|
||||
'google_cloud': _tts_google_cloud,
|
||||
'edge_tts': _tts_edge,
|
||||
}
|
||||
@@ -369,3 +525,55 @@ def generate_tts(
|
||||
def load_timestamps(ts_path: Path) -> list[dict]:
|
||||
"""저장된 타임스탬프 JSON 로드."""
|
||||
return json.loads(ts_path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
# ── Standalone test ──────────────────────────────────────────────
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
if '--test' not in sys.argv:
|
||||
print("사용법: python -m bots.shorts.tts_engine --test")
|
||||
sys.exit(0)
|
||||
|
||||
print("=== TTS Engine Test ===")
|
||||
|
||||
# Test SmartTTSRouter initialization
|
||||
print("\n[1] SmartTTSRouter 초기화:")
|
||||
router = SmartTTSRouter({'budget': 'free'})
|
||||
print(f" budget: {router.budget}")
|
||||
engine = router.select(text_length=100)
|
||||
print(f" select(100chars) → {engine}")
|
||||
assert isinstance(engine, str) and engine, "엔진 선택 실패"
|
||||
|
||||
# Test with medium budget (no API keys → falls back to free engine)
|
||||
router_med = SmartTTSRouter({'budget': 'medium'})
|
||||
engine_med = router_med.select(text_length=500)
|
||||
print(f" medium budget select(500chars) → {engine_med}")
|
||||
assert isinstance(engine_med, str) and engine_med, "medium 엔진 선택 실패"
|
||||
|
||||
# Test usage recording + over-limit detection
|
||||
print("\n[2] 사용량 제한 로직:")
|
||||
router3 = SmartTTSRouter({'budget': 'free'})
|
||||
router3.record_usage('elevenlabs', 9000) # near limit
|
||||
over = router3._is_over_limit('elevenlabs', 900) # 9000+900 > 8000 threshold
|
||||
print(f" elevenlabs 9000자 기록 후 900자 추가 → 한도 초과: {over}")
|
||||
assert over, "한도 초과 감지 실패"
|
||||
|
||||
# Test Edge TTS (always-available free engine) with short text
|
||||
print("\n[3] Edge TTS 음성 생성 (네트워크 필요):")
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
try:
|
||||
wav, timestamps = generate_tts(
|
||||
script={'hook': '테스트입니다', 'body': [], 'closer': ''},
|
||||
output_dir=Path(tmpdir),
|
||||
timestamp='test_20260329',
|
||||
)
|
||||
print(f" WAV 생성: {wav.exists()}, 타임스탬프: {len(timestamps)}단어")
|
||||
assert wav.exists(), "WAV 파일 생성 실패"
|
||||
except Exception as e:
|
||||
print(f" [경고] TTS 실패 (네트워크/의존성 없을 수 있음): {e}")
|
||||
|
||||
print("\n✅ 모든 테스트 통과")
|
||||
|
||||
@@ -413,3 +413,269 @@ def assemble(
|
||||
if tmp_cleanup and work_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
|
||||
# ─── GPU Encoder Detection ────────────────────────────────────
|
||||
|
||||
def _detect_gpu_encoder(ffmpeg: str = 'ffmpeg') -> str:
|
||||
"""
|
||||
Detect available GPU encoder in priority order:
|
||||
nvenc (NVIDIA) > amf (AMD) > qsv (Intel) > libx264 (CPU)
|
||||
|
||||
Returns: encoder name string
|
||||
"""
|
||||
encoders_to_try = [
|
||||
('h264_nvenc', ['-hwaccel', 'cuda']), # NVIDIA
|
||||
('h264_amf', []), # AMD
|
||||
('h264_qsv', ['-hwaccel', 'qsv']), # Intel
|
||||
]
|
||||
|
||||
import tempfile, subprocess
|
||||
|
||||
for encoder, hwaccel_args in encoders_to_try:
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as f:
|
||||
test_out = f.name
|
||||
cmd = (
|
||||
[ffmpeg, '-y', '-loglevel', 'error']
|
||||
+ hwaccel_args
|
||||
+ ['-f', 'lavfi', '-i', 'color=black:s=16x16:r=1',
|
||||
'-t', '0.1',
|
||||
'-c:v', encoder,
|
||||
test_out]
|
||||
)
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=10)
|
||||
Path(test_out).unlink(missing_ok=True)
|
||||
if result.returncode == 0:
|
||||
logger.info(f'[GPU] 인코더 감지: {encoder}')
|
||||
return encoder
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info('[GPU] GPU 인코더 없음 — libx264 사용')
|
||||
return 'libx264'
|
||||
|
||||
|
||||
# ─── Resilient Assembler ─────────────────────────────────────
|
||||
|
||||
class ResilientAssembler:
|
||||
"""
|
||||
Resilient video assembler with:
|
||||
1. Per-clip encoding (fail one → fallback that clip only)
|
||||
2. Timeout per FFmpeg process (5 minutes)
|
||||
3. GPU encoder auto-detection (nvenc/amf/qsv/cpu)
|
||||
4. Progress reporting (logs every clip)
|
||||
|
||||
Use assemble_resilient() instead of the module-level assemble() for better fault tolerance.
|
||||
"""
|
||||
|
||||
CLIP_TIMEOUT = 300 # 5 minutes per clip
|
||||
FINAL_TIMEOUT = 600 # 10 minutes for final assembly
|
||||
|
||||
def __init__(self, cfg: dict = None):
|
||||
"""
|
||||
cfg: shorts_config.json dict (loaded automatically if None)
|
||||
"""
|
||||
self._cfg = cfg or _load_config()
|
||||
self._ffmpeg = _get_ffmpeg()
|
||||
self._encoder = None # Lazy detection
|
||||
|
||||
def _get_encoder(self) -> str:
|
||||
"""Detect and cache GPU encoder."""
|
||||
if self._encoder is None:
|
||||
self._encoder = _detect_gpu_encoder(self._ffmpeg)
|
||||
return self._encoder
|
||||
|
||||
def _encode_clip(self, clip_path: Path, index: int, work_dir: Path) -> Path:
|
||||
"""
|
||||
Encode a single clip to standardized format.
|
||||
|
||||
Returns: path to encoded clip
|
||||
Raises: RuntimeError on failure (triggers fallback)
|
||||
"""
|
||||
out = work_dir / f'encoded_{index:02d}.mp4'
|
||||
encoder = self._get_encoder()
|
||||
|
||||
cmd = [
|
||||
self._ffmpeg, '-y',
|
||||
'-i', str(clip_path),
|
||||
'-c:v', encoder,
|
||||
'-crf', '20' if encoder == 'libx264' else '20',
|
||||
'-preset', 'fast' if encoder == 'libx264' else 'fast',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-an', '-r', '30',
|
||||
str(out),
|
||||
]
|
||||
|
||||
# Adjust args for GPU encoders (they use different quality flags)
|
||||
if encoder != 'libx264':
|
||||
cmd = [
|
||||
self._ffmpeg, '-y',
|
||||
'-i', str(clip_path),
|
||||
'-c:v', encoder,
|
||||
'-b:v', '2M', # Bitrate for GPU encoders
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-an', '-r', '30',
|
||||
str(out),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, timeout=self.CLIP_TIMEOUT
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f'FFmpeg error: {result.stderr.decode(errors="ignore")[-200:]}')
|
||||
logger.info(f'[조립] 클립 {index} 인코딩 완료 ({encoder})')
|
||||
return out
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(f'클립 {index} 인코딩 타임아웃 ({self.CLIP_TIMEOUT}초)')
|
||||
|
||||
def _fallback_clip(self, clip_path: Path, index: int, work_dir: Path) -> Path:
|
||||
"""
|
||||
Fallback clip encoding using libx264 (CPU, always works).
|
||||
"""
|
||||
logger.warning(f'[조립] 클립 {index} 폴백 인코딩 (libx264)')
|
||||
out = work_dir / f'fallback_{index:02d}.mp4'
|
||||
|
||||
cmd = [
|
||||
self._ffmpeg, '-y',
|
||||
'-i', str(clip_path),
|
||||
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-an', '-r', '30',
|
||||
str(out),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=self.CLIP_TIMEOUT)
|
||||
if result.returncode != 0:
|
||||
logger.error(f'[조립] 폴백도 실패 (클립 {index}): {result.stderr.decode(errors="ignore")[-100:]}')
|
||||
return clip_path # Return original as last resort
|
||||
return out
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f'[조립] 폴백 타임아웃 (클립 {index})')
|
||||
return clip_path
|
||||
|
||||
def assemble_resilient(
|
||||
self,
|
||||
clips: list[Path],
|
||||
tts_wav: Path,
|
||||
ass_path: Optional[Path],
|
||||
output_dir: Path,
|
||||
timestamp: str,
|
||||
work_dir: Optional[Path] = None,
|
||||
) -> Path:
|
||||
"""
|
||||
Resilient version of assemble() with per-clip fallback.
|
||||
|
||||
Key differences from assemble():
|
||||
1. Each clip is encoded individually — failure → fallback that clip only
|
||||
2. GPU encoder used when available
|
||||
3. Per-process timeout (5 min per clip)
|
||||
4. Progress logged per clip
|
||||
|
||||
Args:
|
||||
Same as assemble()
|
||||
|
||||
Returns: Path to rendered MP4
|
||||
Raises: RuntimeError only if ALL clips fail or final assembly fails
|
||||
"""
|
||||
import contextlib, shutil
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tmp_cleanup = work_dir is None
|
||||
if work_dir is None:
|
||||
work_dir = output_dir / f'_resilient_{timestamp}'
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Step 1: Encode each clip (with per-clip fallback)
|
||||
encoded = []
|
||||
failed_count = 0
|
||||
for i, clip in enumerate(clips):
|
||||
logger.info(f'[조립] 클립 {i+1}/{len(clips)} 처리 중...')
|
||||
try:
|
||||
enc = self._encode_clip(clip, i, work_dir)
|
||||
encoded.append(enc)
|
||||
except Exception as e:
|
||||
logger.warning(f'[조립] 클립 {i} 인코딩 실패: {e} — 폴백 사용')
|
||||
failed_count += 1
|
||||
fb = self._fallback_clip(clip, i, work_dir)
|
||||
encoded.append(fb)
|
||||
|
||||
if not encoded:
|
||||
raise RuntimeError('[조립] 인코딩된 클립 없음 — 조립 불가')
|
||||
|
||||
if failed_count > 0:
|
||||
logger.warning(f'[조립] {failed_count}/{len(clips)} 클립이 폴백으로 인코딩됨')
|
||||
|
||||
# Step 2: Use the existing assemble() for the rest (concat + audio + subtitles)
|
||||
# This reuses all the battle-tested logic from the original assembler
|
||||
result_path = assemble(
|
||||
clips=encoded,
|
||||
tts_wav=tts_wav,
|
||||
ass_path=ass_path,
|
||||
output_dir=output_dir,
|
||||
timestamp=timestamp,
|
||||
cfg=self._cfg,
|
||||
work_dir=work_dir / 'assemble',
|
||||
)
|
||||
|
||||
logger.info(f'[조립] 탄력적 조립 완료: {result_path.name}')
|
||||
return result_path
|
||||
|
||||
finally:
|
||||
if tmp_cleanup and work_dir.exists():
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
|
||||
# ── Standalone test ──────────────────────────────────────────────
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
|
||||
if '--test' not in sys.argv:
|
||||
print("사용법: python -m bots.shorts.video_assembler --test")
|
||||
sys.exit(0)
|
||||
|
||||
print("=== Video Assembler Test ===")
|
||||
|
||||
# Test GPU encoder detection
|
||||
print("\n[1] GPU 인코더 자동 감지:")
|
||||
ffmpeg_bin = _get_ffmpeg()
|
||||
encoder = _detect_gpu_encoder(ffmpeg_bin)
|
||||
print(f" 감지된 인코더: {encoder}")
|
||||
assert encoder in ('h264_nvenc', 'h264_amf', 'h264_qsv', 'libx264'), \
|
||||
f"알 수 없는 인코더: {encoder}"
|
||||
|
||||
# Test ResilientAssembler encoder caching
|
||||
print("\n[2] ResilientAssembler 초기화 + 인코더 캐싱:")
|
||||
assembler = ResilientAssembler()
|
||||
enc1 = assembler._get_encoder()
|
||||
enc2 = assembler._get_encoder()
|
||||
print(f" 인코더: {enc1}")
|
||||
assert enc1 == enc2, "캐시 불일치"
|
||||
assert assembler._encoder is not None, "캐시 저장 실패"
|
||||
|
||||
# Test duration helpers
|
||||
print("\n[3] 유틸 함수:")
|
||||
# WAV duration (requires existing file — skip if not present)
|
||||
try:
|
||||
import tempfile, wave
|
||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
# Write minimal valid WAV (1s silence at 44100Hz mono)
|
||||
with wave.open(str(tmp_path), 'w') as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(44100)
|
||||
wf.writeframes(b'\x00\x00' * 44100)
|
||||
dur = _get_wav_duration(tmp_path)
|
||||
print(f" WAV 1초 테스트: duration={dur:.2f}s")
|
||||
assert abs(dur - 1.0) < 0.1, f"WAV 길이 오류: {dur}"
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except Exception as e:
|
||||
print(f" [경고] WAV 테스트 건너뜀: {e}")
|
||||
|
||||
print("\n✅ 모든 테스트 통과")
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ from typing import Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
|
||||
+5
-4
@@ -3,12 +3,13 @@
|
||||
{
|
||||
"id": "main",
|
||||
"blog_id": "${BLOG_MAIN_ID}",
|
||||
"name": "테크인사이더",
|
||||
"persona": "tech_insider",
|
||||
"domain": "",
|
||||
"name": "AI? 그게 뭔데?",
|
||||
"persona": "eli",
|
||||
"domain": "eli-ai.blogspot.com",
|
||||
"tagline": "어렵지 않아요, 그냥 읽어봐요",
|
||||
"active": true,
|
||||
"phase": 1,
|
||||
"labels": ["쉬운세상", "숨은보물", "바이브리포트", "팩트체크", "한컷"]
|
||||
"labels": ["AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "앱추천", "재테크", "팩트체크"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+50
-16
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"_comment": "The 4th Path 블로그 자동 수익 엔진 — 엔진 설정 (v3)",
|
||||
"_updated": "2026-03-26",
|
||||
"_updated": "2026-03-29",
|
||||
"writing": {
|
||||
"provider": "openclaw",
|
||||
"provider": "gemini",
|
||||
"_comment_provider": "openclaw=ChatGPT Pro(OAuth), claude_web=Claude Max(웹쿠키), gemini_web=Gemini Pro(웹쿠키), claude=Anthropic API키, gemini=Google AI API키",
|
||||
"options": {
|
||||
"openclaw": {
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"gemini": {
|
||||
"api_key_env": "GEMINI_API_KEY",
|
||||
"model": "gemini-2.5-pro",
|
||||
"model": "gemini-2.5-flash",
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.7
|
||||
}
|
||||
@@ -75,8 +75,45 @@
|
||||
}
|
||||
},
|
||||
"video_generation": {
|
||||
"provider": "sora",
|
||||
"provider": "smart_router",
|
||||
"options": {
|
||||
"smart_router": {
|
||||
"priority": [
|
||||
"kling_free",
|
||||
"veo3",
|
||||
"seedance2",
|
||||
"ffmpeg_slides"
|
||||
],
|
||||
"daily_cost_limit_usd": 0.5,
|
||||
"prefer_free_first": true,
|
||||
"fallback": "ffmpeg_slides"
|
||||
},
|
||||
"kling_free": {
|
||||
"api_url": "https://api.klingai.com/v1",
|
||||
"api_key_env": "KLING_API_KEY",
|
||||
"free_daily_credits": 66,
|
||||
"mode": "standard",
|
||||
"resolution": "720p",
|
||||
"aspect_ratio": "9:16",
|
||||
"audio": true,
|
||||
"cost_per_sec": 0
|
||||
},
|
||||
"veo3": {
|
||||
"api_key_env": "GEMINI_API_KEY",
|
||||
"model": "veo-3.1",
|
||||
"resolution": "720p",
|
||||
"aspect_ratio": "9:16",
|
||||
"audio": true,
|
||||
"cost_per_sec": 0.03
|
||||
},
|
||||
"seedance2": {
|
||||
"provider": "fal.ai",
|
||||
"api_key_env": "FAL_API_KEY",
|
||||
"model": "seedance-2.0",
|
||||
"resolution": "720p",
|
||||
"audio": true,
|
||||
"cost_per_sec": 0.022
|
||||
},
|
||||
"ffmpeg_slides": {
|
||||
"resolution": "1080x1920",
|
||||
"fps": 30,
|
||||
@@ -88,6 +125,7 @@
|
||||
"burn_subtitles": true
|
||||
},
|
||||
"seedance": {
|
||||
"_comment": "레거시 — seedance2/FAL_API_KEY로 대체됨",
|
||||
"api_url": "https://api.seedance2.ai/v1/generate",
|
||||
"api_key_env": "SEEDANCE_API_KEY",
|
||||
"resolution": "1080x1920",
|
||||
@@ -95,11 +133,6 @@
|
||||
"audio": true,
|
||||
"fallback": "ffmpeg_slides"
|
||||
},
|
||||
"sora": {
|
||||
"comment": "OpenAI Sora — API 접근 가능 시 활성화",
|
||||
"api_key_env": "OPENAI_API_KEY",
|
||||
"fallback": "ffmpeg_slides"
|
||||
},
|
||||
"runway": {
|
||||
"api_key_env": "RUNWAY_API_KEY",
|
||||
"model": "gen3a_turbo",
|
||||
@@ -108,7 +141,7 @@
|
||||
"fallback": "ffmpeg_slides"
|
||||
},
|
||||
"veo": {
|
||||
"comment": "Google Veo 3.1 — API 접근 가능 시 활성화",
|
||||
"comment": "Google Veo 3.1 레거시 — veo3로 대체됨",
|
||||
"api_key_env": "GEMINI_API_KEY",
|
||||
"fallback": "ffmpeg_slides"
|
||||
}
|
||||
@@ -163,14 +196,15 @@
|
||||
"analytics": "22:00"
|
||||
},
|
||||
"brand": {
|
||||
"name": "The 4th Path",
|
||||
"sub": "Independent Tech Media",
|
||||
"by": "by 22B Labs",
|
||||
"url": "the4thpath.com",
|
||||
"cta": "팔로우하면 매일 이런 정보를 받습니다"
|
||||
"name": "AI? 그게 뭔데?",
|
||||
"sub": "eli의 쉽고 재미있는 이야기",
|
||||
"by": "by eli",
|
||||
"url": "eli-ai.blogspot.com",
|
||||
"cta": "구독하면 매일 쉽고 재미있는 정보를 받습니다"
|
||||
},
|
||||
"optional_keys": {
|
||||
"SEEDANCE_API_KEY": "Seedance 2.0 AI 영상 생성",
|
||||
"KLING_API_KEY": "Kling 3.0 AI 영상 생성 (66 무료 크레딧/일)",
|
||||
"FAL_API_KEY": "Seedance 2.0 AI 영상 생성 (fal.ai)",
|
||||
"ELEVENLABS_API_KEY": "ElevenLabs 고품질 TTS",
|
||||
"GEMINI_API_KEY": "Google Gemini 글쓰기 / Veo 영상",
|
||||
"RUNWAY_API_KEY": "Runway Gen-3 AI 영상 생성"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"_comment": "코너별 시각 스타일 설정 (쇼츠 영상 프롬프트에 사용)",
|
||||
"_updated": "2026-03-29",
|
||||
"corners": {
|
||||
"쉬운세상": {
|
||||
"caption_template": "hormozi",
|
||||
"color_palette": ["#FFD700", "#FFFFFF", "#000000"],
|
||||
"video_style": "clean minimal bright",
|
||||
"motion_preference": ["ken_burns_in", "pan_right"],
|
||||
"tone": "educational friendly"
|
||||
},
|
||||
"숨은보물": {
|
||||
"caption_template": "tiktok_viral",
|
||||
"color_palette": ["#FF6B6B", "#FFFFFF", "#1A1A1A"],
|
||||
"video_style": "dynamic energetic colorful",
|
||||
"motion_preference": ["glitch_reveal", "pan_left"],
|
||||
"tone": "exciting surprising"
|
||||
},
|
||||
"바이브리포트": {
|
||||
"caption_template": "hormozi",
|
||||
"color_palette": ["#FFD700", "#FFFFFF", "#0D0D0D"],
|
||||
"video_style": "professional cinematic dark",
|
||||
"motion_preference": ["ken_burns_out", "parallax"],
|
||||
"tone": "authoritative analytical"
|
||||
},
|
||||
"팩트체크": {
|
||||
"caption_template": "brand_4thpath",
|
||||
"color_palette": ["#00D4FF", "#FFFFFF", "#0A0A1A"],
|
||||
"video_style": "corporate data-driven precise",
|
||||
"motion_preference": ["rotate_slow", "ken_burns_in"],
|
||||
"tone": "objective factual"
|
||||
},
|
||||
"한컷": {
|
||||
"caption_template": "tiktok_viral",
|
||||
"color_palette": ["#FF6B6B", "#FF9F43", "#FFFFFF"],
|
||||
"video_style": "bold graphic striking",
|
||||
"motion_preference": ["glitch_reveal", "pan_right"],
|
||||
"tone": "punchy viral"
|
||||
},
|
||||
"웹소설": {
|
||||
"caption_template": "brand_4thpath",
|
||||
"color_palette": ["#00D4FF", "#9B59B6", "#0D0D0D"],
|
||||
"video_style": "dramatic atmospheric fantasy",
|
||||
"motion_preference": ["parallax", "ken_burns_in"],
|
||||
"tone": "narrative dramatic"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"caption_template": "hormozi",
|
||||
"color_palette": ["#FFD700", "#FFFFFF", "#000000"],
|
||||
"video_style": "clean professional",
|
||||
"motion_preference": ["ken_burns_in"],
|
||||
"tone": "neutral"
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"korean_relevance": {
|
||||
"max": 30,
|
||||
"description": "한국 독자 관련성",
|
||||
"keywords": ["한국", "국내", "한글", "카카오", "네이버", "쿠팡", "삼성", "LG", "현대", "기아", "배달", "토스", "당근", "야놀자"]
|
||||
"keywords": ["한국", "국내", "한글", "카카오", "네이버", "쿠팡", "삼성", "LG", "현대", "기아", "배달", "토스", "당근", "야놀자", "AI", "GPT", "ChatGPT", "Claude", "Gemini", "Apple", "Google", "iPhone", "갤럭시", "Netflix", "넷플릭스", "YouTube"]
|
||||
},
|
||||
"freshness": {
|
||||
"max": 20,
|
||||
@@ -63,7 +63,7 @@
|
||||
{
|
||||
"id": "clickbait",
|
||||
"description": "클릭베이트성 주제",
|
||||
"patterns": ["충격", "경악", "난리", "ㅋㅋ", "ㅠㅠ", "대박", "레전드", "역대급"]
|
||||
"patterns": ["경악", "난리", "ㅋㅋ", "ㅠㅠ"]
|
||||
}
|
||||
],
|
||||
"evergreen_keywords": [
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"legal_keywords": [
|
||||
"불법", "위법", "처벌", "벌금", "징역", "기소"
|
||||
],
|
||||
"always_manual_review": ["팩트체크"],
|
||||
"always_manual_review": ["팩트체크", "재테크절약"],
|
||||
"min_sources_required": 2,
|
||||
"min_quality_score_for_auto": 75
|
||||
"min_quality_score_for_auto": 101
|
||||
}
|
||||
|
||||
+74
-30
@@ -1,38 +1,82 @@
|
||||
{
|
||||
"rss_feeds": [
|
||||
{
|
||||
"name": "GeekNews",
|
||||
"url": "https://feeds.feedburner.com/geeknews-feed",
|
||||
"category": "tech",
|
||||
"trust_level": "high"
|
||||
},
|
||||
{
|
||||
"name": "ZDNet Korea",
|
||||
"url": "https://www.zdnet.co.kr/rss/rss.php",
|
||||
"category": "tech",
|
||||
"trust_level": "high"
|
||||
},
|
||||
{
|
||||
"name": "Yonhap IT",
|
||||
"url": "https://www.yna.co.kr/rss/it.xml",
|
||||
"category": "tech",
|
||||
"trust_level": "high"
|
||||
},
|
||||
{
|
||||
"name": "Bloter",
|
||||
"url": "https://www.bloter.net/feed",
|
||||
"category": "tech",
|
||||
"trust_level": "high"
|
||||
}
|
||||
{ "name": "GeekNews", "url": "https://feeds.feedburner.com/geeknews-feed", "category": "AI인사이트", "trust_level": "high" },
|
||||
{ "name": "ZDNet Korea", "url": "https://www.zdnet.co.kr/rss/rss.php", "category": "AI인사이트", "trust_level": "high" },
|
||||
{ "name": "연합뉴스 IT", "url": "https://www.yna.co.kr/rss/it.xml", "category": "AI인사이트", "trust_level": "high" },
|
||||
{ "name": "AI타임스", "url": "https://www.aitimes.com/rss/allArticle.xml", "category": "AI인사이트", "trust_level": "medium" },
|
||||
{ "name": "테크크런치 AI", "url": "https://techcrunch.com/category/artificial-intelligence/feed/", "category": "AI인사이트", "trust_level": "high" },
|
||||
{ "name": "MIT 테크리뷰", "url": "https://www.technologyreview.com/feed/", "category": "AI인사이트", "trust_level": "high" },
|
||||
{ "name": "전자신문", "url": "https://www.etnews.com/rss/rss.xml", "category": "AI인사이트", "trust_level": "high" },
|
||||
{ "name": "딥러닝 뉴스", "url": "https://news.google.com/rss/search?q=AI+인공지능&hl=ko&gl=KR&ceid=KR:ko", "category": "AI인사이트", "trust_level": "medium" },
|
||||
{ "name": "Ars Technica AI", "url": "https://feeds.arstechnica.com/arstechnica/technology-lab", "category": "AI인사이트", "trust_level": "high" },
|
||||
|
||||
{ "name": "Bloter", "url": "https://www.bloter.net/feed", "category": "스타트업", "trust_level": "high" },
|
||||
{ "name": "플래텀", "url": "https://platum.kr/feed", "category": "스타트업", "trust_level": "high" },
|
||||
{ "name": "벤처스퀘어", "url": "https://www.venturesquare.net/feed", "category": "스타트업", "trust_level": "medium" },
|
||||
{ "name": "한국경제 IT", "url": "https://www.hankyung.com/feed/it", "category": "스타트업", "trust_level": "high" },
|
||||
{ "name": "테크크런치 스타트업","url": "https://techcrunch.com/category/startups/feed/", "category": "스타트업", "trust_level": "high" },
|
||||
{ "name": "IT동아", "url": "https://it.donga.com/rss/", "category": "스타트업", "trust_level": "medium" },
|
||||
|
||||
{ "name": "연합뉴스 여행", "url": "https://www.yna.co.kr/rss/travel.xml", "category": "여행맛집", "trust_level": "high" },
|
||||
{ "name": "경향신문 여행", "url": "https://www.khan.co.kr/rss/rssdata/kh_travel.xml", "category": "여행맛집", "trust_level": "medium" },
|
||||
{ "name": "한국관광공사", "url": "https://kto.visitkorea.or.kr/rss/rss.kto", "category": "여행맛집", "trust_level": "medium" },
|
||||
{ "name": "대한항공 뉴스", "url": "https://www.koreanair.com/content/koreanair/global/en/footer/about-korean-air/news-and-pr/press-releases.rss.xml", "category": "여행맛집", "trust_level": "medium" },
|
||||
{ "name": "론리플래닛", "url": "https://www.lonelyplanet.com/news/feed", "category": "여행맛집", "trust_level": "medium" },
|
||||
{ "name": "다나와 여행", "url": "https://news.google.com/rss/search?q=국내여행+맛집&hl=ko&gl=KR&ceid=KR:ko", "category": "여행맛집", "trust_level": "medium" },
|
||||
{ "name": "마이리얼트립 블로그","url": "https://blog.myrealtrip.com/feed", "category": "여행맛집", "trust_level": "medium" },
|
||||
|
||||
{ "name": "ITWorld Korea", "url": "https://www.itworld.co.kr/rss/feed", "category": "제품리뷰", "trust_level": "medium" },
|
||||
{ "name": "디지털데일리", "url": "https://www.ddaily.co.kr/rss/rss.xml", "category": "제품리뷰", "trust_level": "medium" },
|
||||
{ "name": "The Verge", "url": "https://www.theverge.com/rss/index.xml", "category": "제품리뷰", "trust_level": "high" },
|
||||
{ "name": "Engadget", "url": "https://www.engadget.com/rss.xml", "category": "제품리뷰", "trust_level": "high" },
|
||||
{ "name": "뽐뿌 뉴스", "url": "https://www.ppomppu.co.kr/rss.php?id=news", "category": "제품리뷰", "trust_level": "medium" },
|
||||
{ "name": "Wired", "url": "https://www.wired.com/feed/rss", "category": "제품리뷰", "trust_level": "high" },
|
||||
|
||||
{ "name": "위키트리", "url": "https://www.wikitree.co.kr/rss/", "category": "생활꿀팁", "trust_level": "medium" },
|
||||
{ "name": "오마이뉴스 라이프", "url": "https://rss2.ohmynews.com/rss/ohmyrss.xml", "category": "생활꿀팁", "trust_level": "medium" },
|
||||
{ "name": "헬스조선", "url": "https://health.chosun.com/site/data/rss/rss.xml", "category": "생활꿀팁", "trust_level": "medium" },
|
||||
{ "name": "조선일보 라이프", "url": "https://www.chosun.com/arc/outboundfeeds/rss/category/life/", "category": "생활꿀팁", "trust_level": "high" },
|
||||
|
||||
{ "name": "Product Hunt", "url": "https://www.producthunt.com/feed", "category": "앱추천", "trust_level": "medium" },
|
||||
{ "name": "테크크런치 앱", "url": "https://techcrunch.com/category/apps/feed/", "category": "앱추천", "trust_level": "high" },
|
||||
{ "name": "앱스토리", "url": "https://www.appstory.co.kr/rss/rss.xml", "category": "앱추천", "trust_level": "medium" },
|
||||
{ "name": "9to5Mac", "url": "https://9to5mac.com/feed/", "category": "앱추천", "trust_level": "high" },
|
||||
{ "name": "Android Authority", "url": "https://www.androidauthority.com/feed/", "category": "앱추천", "trust_level": "high" },
|
||||
|
||||
{ "name": "매일경제 IT", "url": "https://rss.mk.co.kr/rss/30000001/", "category": "재테크", "trust_level": "high" },
|
||||
{ "name": "머니투데이", "url": "https://rss.mt.co.kr/news/mt_news.xml", "category": "재테크", "trust_level": "high" },
|
||||
{ "name": "한국경제 재테크", "url": "https://www.hankyung.com/feed/finance", "category": "재테크", "trust_level": "high" },
|
||||
{ "name": "뱅크샐러드 블로그", "url": "https://blog.banksalad.com/rss.xml", "category": "재테크", "trust_level": "medium" },
|
||||
{ "name": "서울경제", "url": "https://www.sedaily.com/rss/rss.xml", "category": "재테크", "trust_level": "high" },
|
||||
{ "name": "조선비즈 경제", "url": "https://biz.chosun.com/site/data/rss/rss.xml", "category": "재테크", "trust_level": "high" },
|
||||
{ "name": "이데일리 금융", "url": "https://www.edaily.co.kr/rss/rss.asp?media_key=20", "category": "재테크", "trust_level": "high" },
|
||||
|
||||
{ "name": "스포츠조선 연예", "url": "https://sports.chosun.com/site/data/rss/rss.xml", "category": "TV로보는세상","trust_level": "medium" },
|
||||
{ "name": "연합뉴스 연예", "url": "https://www.yna.co.kr/rss/entertainment.xml", "category": "TV로보는세상","trust_level": "high" },
|
||||
{ "name": "한국경제 연예", "url": "https://www.hankyung.com/feed/entertainment", "category": "TV로보는세상","trust_level": "high" },
|
||||
{ "name": "MBC 연예", "url": "https://imnews.imbc.com/rss/entertainment.xml", "category": "TV로보는세상","trust_level": "high" },
|
||||
{ "name": "TV리포트", "url": "https://www.tvreport.co.kr/rss/allArticle.xml", "category": "TV로보는세상","trust_level": "medium" },
|
||||
{ "name": "OSEN 연예", "url": "https://www.osen.co.kr/rss/osen.xml", "category": "TV로보는세상","trust_level": "medium" },
|
||||
|
||||
{ "name": "연합뉴스 팩트체크", "url": "https://www.yna.co.kr/rss/factcheck.xml", "category": "팩트체크", "trust_level": "high" },
|
||||
{ "name": "SBS 뉴스", "url": "https://news.sbs.co.kr/news/SectionRssFeed.do?sectionId=01&plink=RSSREADER", "category": "팩트체크", "trust_level": "high" },
|
||||
{ "name": "KBS 뉴스", "url": "https://news.kbs.co.kr/rss/rss.xml", "category": "팩트체크", "trust_level": "high" },
|
||||
{ "name": "JTBC 뉴스", "url": "https://fs.jtbc.co.kr/RSS/newsflash.xml", "category": "팩트체크", "trust_level": "high" }
|
||||
],
|
||||
"x_keywords": [
|
||||
"바이브코딩",
|
||||
"vibe coding",
|
||||
"AI 자동화",
|
||||
"Claude 사용",
|
||||
"AI 사용법",
|
||||
"ChatGPT 활용",
|
||||
"비개발자 앱",
|
||||
"노코드 AI"
|
||||
"Claude 사용",
|
||||
"인공지능 추천",
|
||||
"앱 추천",
|
||||
"생활꿀팁",
|
||||
"맛집 추천",
|
||||
"스타트업 소식",
|
||||
"재테크 방법",
|
||||
"쇼핑 추천",
|
||||
"TV 예능 화제",
|
||||
"드라마 추천",
|
||||
"넷플릭스 신작"
|
||||
],
|
||||
"github_trending": {
|
||||
"url": "https://github.com/trending",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"_comment": "사용자 의도 설정 — bw init으로 생성/업데이트",
|
||||
"_updated": "2026-03-29",
|
||||
"budget": "free",
|
||||
"level": "beginner",
|
||||
"engines": {
|
||||
"writing": {"provider": "auto"},
|
||||
"tts": {"provider": "auto"},
|
||||
"video": {"provider": "auto"},
|
||||
"image": {"provider": "auto"}
|
||||
},
|
||||
"platforms": ["youtube"],
|
||||
"services": {
|
||||
"openclaw": false,
|
||||
"claude_web": false,
|
||||
"gemini_web": false
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
CONFIG_PATH = BASE_DIR / "config" / "engine.json"
|
||||
|
||||
@@ -103,7 +103,7 @@ async def get_subscriptions():
|
||||
"""구독 정보 + 만료일 계산"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
||||
load_dotenv()
|
||||
|
||||
subscriptions = []
|
||||
for plan in SUBSCRIPTION_PLANS:
|
||||
|
||||
Generated
+2992
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
x-common: &common
|
||||
image: blog-writer:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file: .env
|
||||
environment:
|
||||
- PYTHONPATH=/app
|
||||
volumes:
|
||||
- ./bots:/app/bots
|
||||
- ./config:/app/config
|
||||
- ./templates:/app/templates
|
||||
- ./dashboard/backend:/app/dashboard/backend
|
||||
- ./dashboard/__init__.py:/app/dashboard/__init__.py
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./assets:/app/assets
|
||||
- ./runtime_guard.py:/app/runtime_guard.py
|
||||
- ./blog_engine_cli.py:/app/blog_engine_cli.py
|
||||
- ./blog_runtime.py:/app/blog_runtime.py
|
||||
- ./credentials.json:/app/credentials.json:ro
|
||||
restart: unless-stopped
|
||||
|
||||
services:
|
||||
scheduler:
|
||||
<<: *common
|
||||
container_name: blog-scheduler
|
||||
command: ["python3", "bots/scheduler.py"]
|
||||
|
||||
dashboard:
|
||||
<<: *common
|
||||
container_name: blog-dashboard
|
||||
command: ["python3", "-m", "uvicorn", "dashboard.backend.server:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
ports:
|
||||
- "8080:8080"
|
||||
@@ -0,0 +1,47 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.backends.legacy:build"
|
||||
|
||||
[project]
|
||||
name = "blog-writer"
|
||||
version = "3.0.0.dev0"
|
||||
description = "AI-powered blog and shorts automation engine"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
dependencies = [
|
||||
"click>=8.1",
|
||||
"rich>=13.0",
|
||||
"requests>=2.31",
|
||||
"python-dotenv>=1.0",
|
||||
"edge-tts>=6.1",
|
||||
"Pillow>=10.0",
|
||||
"pydub>=0.25",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
tts = [
|
||||
"openai>=1.0",
|
||||
"elevenlabs>=1.0",
|
||||
]
|
||||
video = [
|
||||
"fal-client>=0.4",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.4",
|
||||
"black>=23.0",
|
||||
"ruff>=0.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
bw = "blogwriter.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["blogwriter*", "bots*", "dashboard*"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ["py311"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
+3
-2
@@ -23,8 +23,9 @@ openai
|
||||
pydub
|
||||
# Phase 2 (YouTube 업로드 진행 표시)
|
||||
google-resumable-media
|
||||
# Phase 3 (엔진 추상화 — 선택적 의존성)
|
||||
# google-generativeai # Gemini Writer / Veo 사용 시 pip install google-generativeai
|
||||
# Phase 3 (엔진 추상화)
|
||||
google-generativeai
|
||||
groq
|
||||
# Shorts Bot (Phase A)
|
||||
edge-tts>=6.1.0
|
||||
# openai-whisper # Edge TTS 단어별 타임스탬프용 (선택, pip install openai-whisper)
|
||||
|
||||
@@ -61,6 +61,10 @@ def ensure_project_runtime(
|
||||
entrypoint: str,
|
||||
required_distributions: list[str] | None = None,
|
||||
) -> None:
|
||||
# Docker 환경에서는 venv 체크를 건너뜀
|
||||
if os.getenv('PYTHONPATH') == '/app' or not VENV_DIR.exists():
|
||||
return
|
||||
|
||||
expected_python = project_python_path()
|
||||
current_python = Path(sys.executable)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user