diff --git a/bots/scheduler.py b/bots/scheduler.py index 137a572..41fa7d3 100644 --- a/bots/scheduler.py +++ b/bots/scheduler.py @@ -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))