feat: /collect, /write 텔레그램 명령어 추가 + eli 페르소나 적용

- cmd_collect: 즉시 글감 수집
- cmd_write [번호] [방향]: 특정 글감 글 작성 + auto pending
- _publish_next(): originals → pending_review 자동 이동
- _call_openclaw: direction 파라미터 지원
- 글쓰기 시스템 프롬프트 eli 블로그 페르소나로 변경
- 기본 코너: 쉬운세상 → AI인사이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JOUNGWOOK KWON
2026-03-30 11:27:02 +09:00
parent 3e2405dff9
commit 9f68133217

View File

@@ -151,12 +151,13 @@ 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', '')
system = (
"당신은 The 4th Path 블로그 엔진의 전문 에디터다. "
"당신은 'AI? 그게 뭔데?' 블로그의 편집자 eli다. "
"비전문가도 쉽게 읽을 수 있는 친근한 톤으로 글을 쓴다. "
"반드시 아래 섹션 헤더 형식만 사용해 완성된 Blogger-ready HTML 원고를 출력하라. "
"본문(BODY)은 HTML로 작성하고, KEY_POINTS는 3줄 이내로 작성한다."
)
@@ -205,8 +206,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'))
@@ -214,18 +215,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)
@@ -239,7 +242,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():
@@ -577,6 +601,65 @@ 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')
count = len(list(topics_dir.glob(f'{today}_*.json')))
await update.message.reply_text(f"✅ 수집 완료! 오늘 글감: {count}\n/topics 로 확인하세요.")
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')
@@ -1124,6 +1207,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))