Phase 5-6: API usage tracking + Calendar/Mail/DEVONthink/OmniFocus/News pipeline
- 파이프라인 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>
This commit is contained in:
225
inbox_processor.py
Normal file
225
inbox_processor.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user