- 파이프라인 42→51노드 확장 (calendar/mail/note 핸들러 추가) - 네이티브 서비스 6개: heic_converter(:8090), chat_bridge(:8091), caldav_bridge(:8092), devonthink_bridge(:8093), inbox_processor, news_digest - 분류기 v2→v3: calendar, reminder, mail, note intent 추가 - Mail Processing Pipeline (7노드, IMAP 폴링) - LaunchAgent plist 6개 + manage_services.sh - migrate-v3.sql: news_digest_log + calendar_events 확장 - 개발 문서 현행화 (CLAUDE.md, QUICK_REFERENCE.md, docs/architecture.md) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
226 lines
7.0 KiB
Python
226 lines
7.0 KiB
Python
"""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")
|
|
DEVONTHINK_BRIDGE_URL = os.getenv("DEVONTHINK_BRIDGE_URL", "http://127.0.0.1:8093")
|
|
|
|
|
|
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": "qwen3.5:9b-q8_0", "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:
|
|
"""DEVONthink 브릿지로 메모 저장."""
|
|
content = cls.get("content") or cls.get("title", "")
|
|
title = cls.get("title", "OmniFocus 메모")
|
|
|
|
try:
|
|
resp = httpx.post(
|
|
f"{DEVONTHINK_BRIDGE_URL}/save",
|
|
json={
|
|
"title": title,
|
|
"content": content,
|
|
"type": "markdown",
|
|
"tags": ["omnifocus", "inbox"],
|
|
},
|
|
timeout=10,
|
|
)
|
|
if resp.json().get("success"):
|
|
logger.info(f"Note saved to DEVONthink: {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()
|