"""OmniFocus Inbox Processor — Inbox 항목 분류 + 라우팅 (LaunchAgent, 5분 주기)""" import json import logging import os import subprocess import sys import httpx from dotenv import load_dotenv load_dotenv() logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger("inbox_processor") GPU_OLLAMA_URL = os.getenv("GPU_OLLAMA_URL", "http://192.168.1.186:11434") CALDAV_BRIDGE_URL = os.getenv("CALDAV_BRIDGE_URL", "http://127.0.0.1:8092") KB_WRITER_URL = os.getenv("KB_WRITER_URL", "http://127.0.0.1:8095") def run_applescript(script: str, timeout: int = 15) -> str: result = subprocess.run( ["osascript", "-e", script], capture_output=True, text=True, timeout=timeout, ) if result.returncode != 0: raise RuntimeError(f"AppleScript error: {result.stderr.strip()}") return result.stdout.strip() def get_inbox_items() -> list[dict]: """OmniFocus Inbox에서 bot-processed 태그가 없는 항목 조회.""" script = ''' tell application "OmniFocus" tell default document set output to "" set inboxTasks to every inbox task repeat with t in inboxTasks set tagNames to {} repeat with tg in (tags of t) set end of tagNames to name of tg end repeat set AppleScript's text item delimiters to "," set tagStr to tagNames as text if tagStr does not contain "bot-processed" then set taskId to id of t set taskName to name of t set taskNote to note of t set output to output & taskId & "|||" & taskName & "|||" & taskNote & "\\n" end if end repeat return output end tell end tell''' try: result = run_applescript(script) items = [] for line in result.strip().split("\n"): if "|||" not in line: continue parts = line.split("|||", 2) items.append({ "id": parts[0].strip(), "name": parts[1].strip() if len(parts) > 1 else "", "note": parts[2].strip() if len(parts) > 2 else "", }) return items except Exception as e: logger.error(f"Failed to get inbox items: {e}") return [] def mark_processed(task_id: str) -> None: """항목에 bot-processed 태그 추가.""" script = f''' tell application "OmniFocus" tell default document try set theTag to first tag whose name is "bot-processed" on error set theTag to make new tag with properties {{name:"bot-processed"}} end try set theTask to first flattened task whose id is "{task_id}" add theTag to tags of theTask end tell end tell''' try: run_applescript(script) logger.info(f"Marked as processed: {task_id}") except Exception as e: logger.error(f"Failed to mark processed {task_id}: {e}") def complete_task(task_id: str) -> None: """OmniFocus 항목 완료 처리.""" script = f''' tell application "OmniFocus" tell default document set theTask to first flattened task whose id is "{task_id}" mark complete theTask end tell end tell''' try: run_applescript(script) logger.info(f"Completed: {task_id}") except Exception as e: logger.error(f"Failed to complete {task_id}: {e}") def classify_item(name: str, note: str) -> dict: """Qwen 3.5로 항목 분류.""" prompt = f"""OmniFocus Inbox 항목을 분류하세요. JSON만 출력. {{ "type": "calendar|note|task|reminder", "title": "제목/일정 이름", "start": "YYYY-MM-DDTHH:MM:SS (calendar/reminder일 때)", "location": "장소 (calendar일 때, 없으면 null)", "content": "메모 내용 (note일 때)" }} type 판단: - calendar: 시간이 명시된 일정/약속/회의 - reminder: 알림/리마인드 (시간 기반) - note: 메모/기록/아이디어 - task: 할 일/업무 (OmniFocus에 유지) 항목: {name} {f'메모: {note}' if note else ''}""" try: resp = httpx.post( f"{GPU_OLLAMA_URL}/api/generate", json={"model": "id-9b:latest", "system": "/no_think", "prompt": prompt, "stream": False, "format": "json", "think": False}, timeout=15, ) return json.loads(resp.json()["response"]) except Exception as e: logger.error(f"Classification failed: {e}") return {"type": "task", "title": name, "content": note} def route_calendar(cls: dict, task_id: str) -> None: """CalDAV 브릿지로 일정 생성.""" try: resp = httpx.post( f"{CALDAV_BRIDGE_URL}/calendar/create", json={ "title": cls.get("title", ""), "start": cls.get("start", ""), "location": cls.get("location"), }, timeout=10, ) if resp.json().get("success"): logger.info(f"Calendar event created: {cls.get('title')}") mark_processed(task_id) complete_task(task_id) else: logger.error(f"Calendar create failed: {resp.text[:200]}") mark_processed(task_id) except Exception as e: logger.error(f"Calendar routing failed: {e}") mark_processed(task_id) def route_note(cls: dict, task_id: str) -> None: """kb_writer로 메모 저장.""" content = cls.get("content") or cls.get("title", "") title = cls.get("title", "OmniFocus 메모") try: resp = httpx.post( f"{KB_WRITER_URL}/save", json={ "title": title, "content": content, "type": "note", "tags": ["omnifocus", "inbox"], "username": "inbox-processor", "source": "omnifocus", "topic": "omnifocus", }, timeout=10, ) if resp.json().get("success"): logger.info(f"Note saved to KB: {title}") mark_processed(task_id) complete_task(task_id) except Exception as e: logger.error(f"Note routing failed: {e}") mark_processed(task_id) def route_task(cls: dict, task_id: str) -> None: """Task는 OmniFocus에 유지. 태그만 추가.""" mark_processed(task_id) logger.info(f"Task kept in OmniFocus: {cls.get('title', '')}") def main(): logger.info("Inbox processor started") items = get_inbox_items() if not items: logger.info("No unprocessed inbox items") return logger.info(f"Processing {len(items)} inbox items") for item in items: logger.info(f"Processing: {item['name']}") cls = classify_item(item["name"], item["note"]) item_type = cls.get("type", "task") if item_type == "calendar" or item_type == "reminder": route_calendar(cls, item["id"]) elif item_type == "note": route_note(cls, item["id"]) else: # task route_task(cls, item["id"]) logger.info("Inbox processing complete") if __name__ == "__main__": main()