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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user